use std::path::{Path, PathBuf};
use crate::capture::{AnchorList, FlatKind, FlatTok, shares_previous_span};
#[derive(Clone, Copy, Debug)]
pub(crate) struct Caps {
pub files: usize,
pub file_bytes: u64,
pub total_bytes: u64,
pub dirs: usize,
pub depth: usize,
}
impl Default for Caps {
fn default() -> Self {
Self {
files: 4096,
file_bytes: 4 << 20,
total_bytes: 32 << 20,
dirs: 4096,
depth: 32,
}
}
}
pub(crate) struct Search<'a> {
pub dir: &'a Path,
pub entry_names: &'a [&'a str],
pub caps: Caps,
}
pub(crate) struct Slice {
pub path: PathBuf,
pub text: String,
pub anchors: AnchorList,
pub line: usize,
pub column: usize,
}
impl Search<'_> {
pub(crate) fn find(
&self,
toks: &[FlatTok],
mut accept: impl FnMut(&Path) -> bool,
) -> Option<Slice> {
if toks.is_empty() {
return None;
}
let quoted = quoted_includes(toks);
let mut first: Option<Slice> = None;
let mut budget = self.caps.total_bytes;
for path in self.candidate_files() {
let Ok(meta) = std::fs::metadata(&path) else {
continue;
};
let len = meta.len();
if len > self.caps.file_bytes {
continue;
}
if len > budget {
break;
}
budget -= len;
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let Some(sites) = sites(&text, self.entry_names) else {
continue;
};
for site in sites {
let Some(found) = verify(&text, site.body, toks) else {
continue;
};
if !accept(&path) {
continue;
}
let (line, column) = line_column(&text, found.start);
let slice = Slice {
path: path.clone(),
text: text[found.start..found.end].to_owned(),
anchors: found.anchors,
line,
column,
};
if headers_exist_beside(&slice.path, "ed) {
return Some(slice);
}
if first.is_none() {
first = Some(slice);
}
}
}
first
}
fn candidate_files(&self) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut dirs = 0usize;
self.walk(self.dir, 0, &mut dirs, &mut out);
out.sort();
out
}
fn walk(&self, dir: &Path, depth: usize, dirs: &mut usize, out: &mut Vec<PathBuf>) {
if depth > self.caps.depth || *dirs >= self.caps.dirs || out.len() >= self.caps.files {
return;
}
*dirs += 1;
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
let mut files = Vec::new();
let mut subdirs = Vec::new();
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if name.starts_with('.') {
continue;
}
let Ok(kind) = entry.file_type() else {
continue;
};
if kind.is_dir() {
if name == "target" {
continue;
}
subdirs.push(entry.path());
} else if kind.is_file() && name.ends_with(".rs") {
files.push(entry.path());
}
}
files.sort();
subdirs.sort();
for file in files {
if out.len() >= self.caps.files {
return;
}
out.push(file);
}
for subdir in subdirs {
self.walk(&subdir, depth + 1, dirs, out);
}
}
}
fn quoted_includes(toks: &[FlatTok]) -> Vec<String> {
if let [only] = toks
&& only.kind() == FlatKind::Literal
&& let Some(text) = crate::capture::string_literal_text(only.text())
{
return quoted_includes_in_text(&text);
}
let mut out = Vec::new();
for window in toks.windows(3) {
let [hash, name, operand] = window else {
continue;
};
if hash.kind() != FlatKind::Punct || hash.text() != "#" {
continue;
}
if name.kind() != FlatKind::Ident || !matches!(name.text(), "include" | "include_next") {
continue;
}
if operand.kind() != FlatKind::Literal {
continue;
}
if let Some(header) = crate::capture::string_literal_text(operand.text()) {
out.push(header);
}
}
out
}
fn quoted_includes_in_text(text: &str) -> Vec<String> {
let mut out = Vec::new();
for line in text.lines() {
let line = line.trim_start();
let Some(rest) = line.strip_prefix('#') else {
continue;
};
let rest = rest.trim_start();
let rest = rest
.strip_prefix("include_next")
.or_else(|| rest.strip_prefix("include"))
.map(str::trim_start);
let Some(rest) = rest else {
continue;
};
if let Some(inner) = rest.strip_prefix('"')
&& let Some(end) = inner.find('"')
{
out.push(inner[..end].to_owned());
}
}
out
}
fn headers_exist_beside(path: &Path, quoted: &[String]) -> bool {
if quoted.is_empty() {
return true;
}
let Some(dir) = path.parent() else {
return false;
};
quoted.iter().all(|header| dir.join(header).is_file())
}
fn line_column(text: &str, at: usize) -> (usize, usize) {
let before = &text[..at];
let line = before.bytes().filter(|b| *b == b'\n').count() + 1;
let start = before.rfind('\n').map_or(0, |i| i + 1);
(line, before[start..].chars().count())
}
struct Site {
body: std::ops::Range<usize>,
named: bool,
}
fn sites(text: &str, names: &[&str]) -> Option<Vec<Site>> {
let bytes = text.as_bytes();
let mut out: Vec<Site> = Vec::new();
let mut open: Vec<(usize, u8, Option<bool>)> = Vec::new();
let mut ident: Option<std::ops::Range<usize>> = None;
let mut bang: Option<std::ops::Range<usize>> = None;
let mut at = 0usize;
while at < bytes.len() {
let b = bytes[at];
if b.is_ascii_whitespace() {
at += 1;
continue;
}
if b == b'/' && bytes.get(at + 1) == Some(&b'/') {
at = bytes[at..]
.iter()
.position(|c| *c == b'\n')
.map_or(bytes.len(), |i| at + i + 1);
continue;
}
if b == b'/' && bytes.get(at + 1) == Some(&b'*') {
at = block_comment_end(bytes, at)?;
continue;
}
if b == b'"' {
at = string_end(bytes, at)?;
ident = None;
bang = None;
continue;
}
if b == b'\'' {
at = char_or_lifetime_end(text, at)?;
ident = None;
bang = None;
continue;
}
if is_ident_start(b) {
let end = ident_end(bytes, at);
let word = &text[at..end];
if let Some(after) = raw_string_end(bytes, end, word) {
at = after;
ident = None;
bang = None;
continue;
}
if matches!(word, "b" | "c") && bytes.get(end) == Some(&b'"') {
at = string_end(bytes, end)?;
ident = None;
bang = None;
continue;
}
ident = Some(at..end);
bang = None;
at = end;
continue;
}
if b == b'!' {
bang = ident.take();
at += 1;
continue;
}
if let Some(close) = closing_delimiter(b) {
let named = bang
.take()
.map(|range| names.contains(&&text[range.clone()]));
open.push((at + 1, close, named));
ident = None;
at += 1;
continue;
}
if matches!(b, b')' | b']' | b'}') {
let (start, close, named) = open.pop()?;
if close != b {
return None;
}
if let Some(named) = named {
out.push(Site {
body: start..at,
named,
});
}
ident = None;
bang = None;
at += 1;
continue;
}
ident = None;
bang = None;
at += 1;
}
out.sort_by_key(|site| (!site.named, site.body.start));
Some(out)
}
fn closing_delimiter(b: u8) -> Option<u8> {
match b {
b'(' => Some(b')'),
b'[' => Some(b']'),
b'{' => Some(b'}'),
_ => None,
}
}
fn is_ident_start(b: u8) -> bool {
b.is_ascii_alphabetic() || b == b'_' || b >= 0x80
}
fn is_ident_continue(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b >= 0x80
}
fn ident_end(bytes: &[u8], at: usize) -> usize {
let mut end = at + 1;
while end < bytes.len() && is_ident_continue(bytes[end]) {
end += 1;
}
end
}
fn block_comment_end(bytes: &[u8], at: usize) -> Option<usize> {
let mut depth = 0usize;
let mut i = at;
while i + 1 < bytes.len() {
match (bytes[i], bytes[i + 1]) {
(b'/', b'*') => {
depth += 1;
i += 2;
}
(b'*', b'/') => {
depth -= 1;
i += 2;
if depth == 0 {
return Some(i);
}
}
_ => i += 1,
}
}
None
}
fn string_end(bytes: &[u8], at: usize) -> Option<usize> {
let mut i = at + 1;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'"' => return Some(i + 1),
_ => i += 1,
}
}
None
}
fn raw_string_end(bytes: &[u8], at: usize, prefix: &str) -> Option<usize> {
if !matches!(prefix, "r" | "br" | "cr") {
return None;
}
let mut i = at;
while bytes.get(i) == Some(&b'#') {
i += 1;
}
let hashes = i - at;
if bytes.get(i) != Some(&b'"') {
return None;
}
i += 1;
while i < bytes.len() {
if bytes[i] == b'"' {
let after = i + 1;
if bytes.len() >= after + hashes
&& bytes[after..after + hashes].iter().all(|b| *b == b'#')
{
return Some(after + hashes);
}
}
i += 1;
}
Some(bytes.len())
}
fn char_or_lifetime_end(text: &str, at: usize) -> Option<usize> {
let bytes = text.as_bytes();
let rest = text.get(at + 1..)?;
let mut chars = rest.char_indices();
let Some((_, first)) = chars.next() else {
return Some(bytes.len());
};
if first == '\\' {
let mut i = at + 1;
while i < bytes.len() {
match bytes[i] {
b'\\' => i += 2,
b'\'' => return Some(i + 1),
_ => i += 1,
}
}
return None;
}
if let Some((next, _)) = chars.next() {
if rest.as_bytes()[next] == b'\'' {
return Some(at + 1 + next + 1);
}
} else {
return Some(bytes.len());
}
if is_ident_start(bytes[at + 1]) {
return Some(ident_end(bytes, at + 1));
}
Some(at + 1)
}
struct Match {
start: usize,
end: usize,
anchors: AnchorList,
}
fn verify(text: &str, body: std::ops::Range<usize>, toks: &[FlatTok]) -> Option<Match> {
let bytes = text.as_bytes();
let mut at = body.start;
let mut anchors = AnchorList::with_capacity(toks.len());
let mut start = None;
let mut end = body.start;
let mut prev: Option<&FlatTok> = None;
for tok in toks {
if let Some(prev) = prev
&& shares_previous_span(prev, tok)
{
continue;
}
prev = Some(tok);
at = skip_trivia(bytes, at, body.end)?;
let spelling = tok.text();
let stop = at + spelling.len();
if stop > body.end || !text[at..body.end].starts_with(spelling) {
return None;
}
if matches!(tok.kind(), FlatKind::Ident | FlatKind::Literal)
&& bytes.get(stop).copied().is_some_and(is_ident_continue)
{
return None;
}
if start.is_none() {
start = Some(at);
}
let base = start.expect("the first token set the start");
anchors.push(((at - base) as u32, (stop - base) as u32, tok.span()));
at = stop;
end = stop;
}
let start = start?;
if skip_trivia(bytes, at, body.end)? != body.end {
return None;
}
if end.checked_sub(start)? > u32::MAX as usize {
return None;
}
Some(Match {
start,
end,
anchors,
})
}
fn skip_trivia(bytes: &[u8], mut at: usize, limit: usize) -> Option<usize> {
while at < limit {
if bytes[at].is_ascii_whitespace() {
at += 1;
continue;
}
if bytes[at] == b'/' && bytes.get(at + 1) == Some(&b'/') {
at = bytes[at..limit]
.iter()
.position(|c| *c == b'\n')
.map_or(limit, |i| at + i + 1);
continue;
}
if bytes[at] == b'/' && bytes.get(at + 1) == Some(&b'*') {
let end = block_comment_end(&bytes[..limit], at)?;
at = end;
continue;
}
break;
}
Some(at)
}
#[cfg(test)]
mod tests {
use super::*;
fn found(text: &str, names: &[&str]) -> Vec<String> {
sites(text, names)
.expect("the fixture scans")
.into_iter()
.map(|site| text[site.body].to_owned())
.collect()
}
#[test]
fn a_site_is_an_identifier_a_bang_and_a_delimiter() {
assert_eq!(found("c99! { int x; }", &["c99"]), vec![" int x; "]);
assert_eq!(found("cinrs::c99!(int x;)", &["c99"]), vec!["int x;"]);
assert_eq!(found("c99![int x;]", &["c99"]), vec!["int x;"]);
assert_eq!(found("c99 /*x*/ ! { a }", &["c99"]), vec![" a "]);
}
#[test]
fn a_name_that_was_not_asked_for_is_only_a_bare_site() {
let text = "c99! { a }\nother! { b }";
assert_eq!(found(text, &["c99"]), vec![" a ", " b "]);
assert_eq!(found(text, &[]), vec![" a ", " b "]);
assert_eq!(
found("x99! { a }\nc99! { b }", &["c99"]),
vec![" b ", " a "]
);
}
#[test]
fn an_identifier_that_merely_ends_with_the_name_is_not_a_site() {
assert_eq!(found("xc99! { a }", &["c99"]), vec![" a "]);
assert!(!found("xc99! { a }", &["c99"]).is_empty());
let sites = sites("xc99! { a }", &["c99"]).expect("scans");
assert!(!sites[0].named);
}
#[test]
fn a_comment_or_a_string_is_not_code() {
assert_eq!(found("// c99! { a }\nc99! { b }", &["c99"]), vec![" b "]);
assert_eq!(found("/* c99! { a } */ c99! { b }", &["c99"]), vec![" b "]);
assert_eq!(
found("/* /* c99! { a } */ */ c99! { b }", &["c99"]),
vec![" b "]
);
assert_eq!(
found(r#"let s = "c99! { a }"; c99! { b }"#, &["c99"]),
vec![" b "]
);
assert_eq!(
found(
r##"let s = r#"c99! { a } "unbalanced { "#; c99! { b }"##,
&["c99"]
),
vec![" b "]
);
assert_eq!(found(r#"let s = b"{"; c99! { b }"#, &["c99"]), vec![" b "]);
}
#[test]
fn a_lifetime_is_not_a_character_literal() {
assert_eq!(
found(
"fn f<'a>(x: &'a str) -> &'static str { x }\nc99! { a }",
&["c99"]
),
vec![" a "]
);
assert_eq!(
found("'outer: loop { break 'outer; }\nc99! { a }", &["c99"]),
vec![" a "]
);
assert_eq!(found("let c = '}'; c99! { a }", &["c99"]), vec![" a "]);
assert_eq!(found(r#"let c = '\''; c99! { a }"#, &["c99"]), vec![" a "]);
assert_eq!(found(r#"let c = '\\'; c99! { a }"#, &["c99"]), vec![" a "]);
assert_eq!(found("let c = b'{'; c99! { a }", &["c99"]), vec![" a "]);
}
#[test]
fn a_raw_identifier_is_not_a_raw_string() {
assert_eq!(found("let r#match = 1; c99! { a }", &["c99"]), vec![" a "]);
}
#[test]
fn nested_invocations_are_all_candidates() {
let text = "outer! { c99! { a } }";
assert_eq!(found(text, &["c99"]), vec![" a ", " c99! { a } "]);
}
#[test]
fn a_file_that_cannot_be_scanned_is_passed_over() {
assert!(sites("} c99! { a }", &["c99"]).is_none());
assert!(sites("c99! ( a } ", &["c99"]).is_none());
assert!(sites("/* c99! { a }", &["c99"]).is_none());
}
#[test]
fn a_line_and_column_are_counted_the_way_a_span_counts_them() {
assert_eq!(line_column("abc", 0), (1, 0));
assert_eq!(line_column("ab\ncd", 4), (2, 1));
assert_eq!(line_column("// ★\nx", 8), (2, 1));
assert_eq!(line_column("★x", "★".len()), (1, 1));
}
use std::str::FromStr;
use proc_macro2::TokenStream;
use crate::capture::flat_tokens;
struct Crate {
dir: PathBuf,
}
impl Crate {
fn new(name: &str) -> Self {
let dir =
std::env::temp_dir().join(format!("cinrs-locate-{}-{name}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).expect("a temporary directory");
Self { dir }
}
fn file(&self, name: &str, text: &str) -> &Self {
let path = self.dir.join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("a temporary directory");
}
std::fs::write(path, text).expect("a writable temporary file");
self
}
fn find(&self, input: &str) -> Option<Slice> {
self.find_with(input, Caps::default())
}
fn find_with(&self, input: &str, caps: Caps) -> Option<Slice> {
let toks = flat_tokens(TokenStream::from_str(input).expect("the input lexes"));
Search {
dir: &self.dir,
entry_names: &["c99"],
caps,
}
.find(&toks, |_| true)
}
}
impl Drop for Crate {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.dir).ok();
}
}
fn found_in(krate: &Crate, slice: &Slice) -> String {
slice
.path
.strip_prefix(&krate.dir)
.expect("the match is inside the crate")
.to_string_lossy()
.replace('\\', "/")
}
#[test]
fn the_invocation_is_found_among_decoys() {
let body = "int fact(int n) { return n == 0 ? 1 : n * fact(n - 1); }";
let krate = Crate::new("decoys");
krate
.file(
"src/other.rs",
"cinrs::c99! { int g(void) { return 2; } }\n",
)
.file(
"src/comment.rs",
&format!("// cinrs::c99! {{ {body} }}\n/* c99! {{ {body} }} */\n"),
)
.file(
"src/string.rs",
&format!("const S: &str = r#\"c99! {{ {body} }}\"#;\n"),
)
.file(
"src/prefix.rs",
"c99! { int fact(int n) { return n == 0 ? 1 : n }\n",
)
.file(
"src/real.rs",
&format!(
"fn f<'a>(s: &'a str) -> char {{ 'outer: loop {{ break 'outer }} '}}' }}\n\
const R: &str = r##\"c99! {{ }} \"#\"##;\n\
const C: char = '\\'';\n\
mod inner {{\n cinrs::c99! {{ {body} }}\n}}\n"
),
);
let slice = krate.find(body).expect("the invocation is found");
assert_eq!(found_in(&krate, &slice), "src/real.rs");
assert_eq!(slice.text, body);
assert_eq!((slice.line, slice.column), (5, 18));
let tokens = flat_tokens(TokenStream::from_str(body).expect("lexes")).len();
assert_eq!(slice.anchors.len(), tokens);
let mut last = 0;
for (start, end, _) in &slice.anchors {
assert!(last <= *start && start <= end && *end as usize <= slice.text.len());
last = *end;
}
}
#[test]
fn a_renamed_import_is_found_without_the_name() {
let krate = Crate::new("renamed");
krate.file(
"src/lib.rs",
"use cinrs::c99 as compile_c;\ncompile_c! { int x; }\n",
);
let slice = krate.find("int x;").expect("found by the bare search");
assert_eq!(slice.text, "int x;");
}
#[test]
fn every_delimiter_holds_a_body() {
for (open, close) in [('{', '}'), ('(', ')'), ('[', ']')] {
let krate = Crate::new(&format!("delim-{open}"));
krate.file("src/lib.rs", &format!("c99!{open} int x; {close}\n"));
assert_eq!(
krate.find("int x;").expect("found").text,
"int x;",
"{open}{close}"
);
}
}
#[test]
fn a_body_that_differs_from_the_tokens_is_not_matched() {
let krate = Crate::new("unsaved");
krate.file("src/lib.rs", "c99! { int x = 1; }\n");
assert!(krate.find("int x = 2;").is_none());
assert!(krate.find("int x = 1").is_none());
assert!(krate.find("int x = 1;;").is_none());
krate.file("src/lib.rs", "c99! { int xs; }\n");
assert!(krate.find("int x;").is_none());
krate.file("src/lib.rs", "c99! { int x = 10UL; }\n");
assert!(krate.find("int x = 10;").is_none());
assert!(krate.find("int x = 10UL;").is_some());
}
#[test]
fn comments_inside_the_body_are_skipped_and_kept() {
let krate = Crate::new("comments");
krate.file(
"src/lib.rs",
"c99! {\n int x; // a comment\n /* and\n another */ int y;\n}\n",
);
let slice = krate.find("int x; int y;").expect("found");
assert_eq!(
slice.text,
"int x; // a comment\n /* and\n another */ int y;"
);
}
#[test]
fn the_first_of_several_matches_wins_deterministically() {
let krate = Crate::new("several");
krate
.file("src/a.rs", "c99! { int x; }\n")
.file("src/b.rs", "c99! { int x; }\n")
.file("lib.rs", "c99! { int x; }\n");
assert_eq!(
found_in(&krate, &krate.find("int x;").expect("found")),
"lib.rs"
);
}
#[test]
fn the_headers_a_unit_includes_by_name_are_read_off_its_tokens() {
let toks = flat_tokens(
TokenStream::from_str("#include \"point.h\"\n#include <stdio.h>\nint x;")
.expect("lexes"),
);
assert_eq!(quoted_includes(&toks), vec!["point.h".to_owned()]);
let literal = flat_tokens(
TokenStream::from_str("r#\"\n #include \"a/b.h\"\n#include <stdio.h>\n\"#")
.expect("lexes"),
);
assert_eq!(quoted_includes(&literal), vec!["a/b.h".to_owned()]);
let plain = flat_tokens(TokenStream::from_str("int x;").expect("lexes"));
assert!(quoted_includes(&plain).is_empty());
}
#[test]
fn the_candidate_whose_directory_holds_the_header_is_preferred() {
let krate = Crate::new("prefer");
std::fs::create_dir_all(krate.dir.join("src/with")).expect("a temporary directory");
krate
.file("src/a.rs", "c99! { #include \"h.h\"\nint x; }\n")
.file("src/with/b.rs", "c99! { #include \"h.h\"\nint x; }\n")
.file("src/with/h.h", "int declared(void);\n");
let slice = krate
.find("#include \"h.h\"\nint x;")
.expect("the invocation is found");
assert_eq!(found_in(&krate, &slice), "src/with/b.rs");
std::fs::remove_file(krate.dir.join("src/with/h.h")).expect("a removable file");
let slice = krate
.find("#include \"h.h\"\nint x;")
.expect("the invocation is found");
assert_eq!(found_in(&krate, &slice), "src/a.rs");
}
#[test]
fn a_candidate_the_caller_refuses_is_passed_over() {
let krate = Crate::new("accept");
krate
.file("src/a.rs", "include_c99!(\"x.c\");\n")
.file("src/b.rs", "include_c99!(\"x.c\");\n");
let toks = flat_tokens(TokenStream::from_str("\"x.c\"").expect("lexes"));
let search = Search {
dir: &krate.dir,
entry_names: &["include_c99"],
caps: Caps::default(),
};
let slice = search
.find(&toks, |path| path.ends_with("b.rs"))
.expect("the second is accepted");
assert_eq!(found_in(&krate, &slice), "src/b.rs");
}
#[test]
fn target_and_hidden_directories_are_not_searched() {
let krate = Crate::new("skipped");
krate
.file("target/debug/build/generated.rs", "c99! { int x; }\n")
.file(".hidden/lib.rs", "c99! { int x; }\n");
assert!(krate.find("int x;").is_none());
}
#[test]
fn the_caps_stop_the_search() {
let krate = Crate::new("caps");
krate
.file(
"src/a.rs",
&format!("// {}\nc99! {{ int y; }}\n", "pad".repeat(64)),
)
.file("src/b.rs", "c99! { int x; }\n");
assert!(krate.find("int x;").is_some());
let one_file = Caps {
files: 1,
..Caps::default()
};
assert!(krate.find_with("int x;", one_file).is_none());
let few_bytes = Caps {
total_bytes: 100,
..Caps::default()
};
assert!(krate.find_with("int x;", few_bytes).is_none());
let small_files = Caps {
file_bytes: 20,
..Caps::default()
};
assert!(krate.find_with("int x;", small_files).is_some());
let no_dirs = Caps {
dirs: 0,
..Caps::default()
};
assert!(krate.find_with("int x;", no_dirs).is_none());
}
}