use std::collections::BTreeMap;
use std::path::{Component, Path, PathBuf};
use codehelion_helper::CompileCommandSelector;
use codehelion_helper_protocol::compile_commands::RecordedCommand;
const LOCATIONS: [&str; 2] = ["compile_commands.json", "build/compile_commands.json"];
pub(crate) struct Entry {
pub(crate) file: PathBuf,
arguments: Result<ValidatedArguments, String>,
pub(crate) definitions: Vec<String>,
pub(crate) selector: CompileCommandSelector,
}
impl Entry {
pub(crate) fn arguments(&self) -> Result<&ValidatedArguments, &str> {
self.arguments.as_ref().map_err(String::as_str)
}
}
pub(crate) struct ValidatedArguments(Vec<String>);
impl ValidatedArguments {
fn parse(arguments: &[String]) -> Result<Self, String> {
let mut accepted = Vec::with_capacity(arguments.len());
let mut index = 0;
while index < arguments.len() {
let argument = &arguments[index];
if explicitly_forbidden(argument) {
return Err(format!("compiler argument is not allowed: {argument}"));
}
if SAFE_FLAGS.contains(&argument.as_str()) {
accepted.push(argument.clone());
index += 1;
continue;
}
if let Some(value) = joined_short_value(argument) {
require_nonempty(argument, value)?;
accepted.push(argument.clone());
index += 1;
continue;
}
if let Some(value) = joined_long_value(argument) {
require_nonempty(argument, value)?;
accepted.push(argument.clone());
index += 1;
continue;
}
if SAFE_WITH_VALUE.contains(&argument.as_str()) {
let Some(value) = arguments.get(index + 1) else {
return Err(format!("compiler argument requires a value: {argument}"));
};
if value.is_empty() {
return Err(format!("compiler argument has an empty value: {argument}"));
}
accepted.push(argument.clone());
accepted.push(value.clone());
index += 2;
continue;
}
if discard_without_value(argument) {
index += 1;
continue;
}
return Err(format!("compiler argument is not allowed: {argument}"));
}
Ok(Self(accepted))
}
pub(crate) fn as_slice(&self) -> &[String] {
&self.0
}
pub(crate) fn reads_within(&self, boundary: &Path) -> bool {
let boundary = canonical(boundary);
let mut directory = boundary.clone();
let mut index = 0;
while index < self.0.len() {
let argument = &self.0[index];
let (option, value, consumed) = match argument.as_str() {
"-include" | "-imacros" | "-working-directory" => {
let Some(value) = self.0.get(index + 1) else {
return false;
};
(argument.as_str(), value.as_str(), 2)
}
_ => {
if let Some(value) = argument.strip_prefix("-working-directory=") {
("-working-directory", value, 1)
} else {
index += 1;
continue;
}
}
};
let path = canonical(&resolve(Some(&directory), Path::new(value)));
if !path.starts_with(&boundary) {
return false;
}
if option == "-working-directory" {
directory = path;
}
index += consumed;
}
true
}
}
const SAFE_FLAGS: &[&str] = &[
"-ansi",
"-fPIC",
"-fPIE",
"-fasynchronous-unwind-tables",
"-fblocks",
"-fborland-extensions",
"-fdeclspec",
"-fdelayed-template-parsing",
"-fexceptions",
"-ffreestanding",
"-fms-compatibility",
"-fms-extensions",
"-fno-blocks",
"-fno-builtin",
"-fno-exceptions",
"-fno-pic",
"-fno-pie",
"-fno-rtti",
"-fno-signed-char",
"-fno-threadsafe-statics",
"-fno-unsigned-char",
"-fno-use-cxa-atexit",
"-fno-wchar",
"-fobjc-arc",
"-fobjc-weak",
"-fopenmp",
"-fpic",
"-fpie",
"-frtti",
"-fshort-enums",
"-fshort-wchar",
"-fsigned-char",
"-fstack-protector",
"-fstack-protector-all",
"-fstack-protector-strong",
"-fsyntax-only",
"-fthreadsafe-statics",
"-funsigned-char",
"-funwind-tables",
"-fuse-cxa-atexit",
"-fwchar",
"-m32",
"-m64",
"-malign-double",
"-mno-align-double",
"-mno-red-zone",
"-mred-zone",
"-nobuiltininc",
"-nostdinc",
"-nostdinc++",
"-nostdsysteminc",
"-pthread",
"-undef",
];
const SAFE_WITH_VALUE: &[&str] = &[
"--sysroot",
"--target",
"-D",
"-F",
"-I",
"-U",
"-arch",
"-idirafter",
"-iframework",
"-iframeworkwithsysroot",
"-imacros",
"-include",
"-iprefix",
"-iquote",
"-isystem",
"-isysroot",
"-iwithprefix",
"-iwithprefixbefore",
"-std",
"-target",
"-working-directory",
"-x",
];
fn joined_short_value(argument: &str) -> Option<&str> {
["-D", "-U", "-I", "-F"].into_iter().find_map(|option| {
argument
.strip_prefix(option)
.filter(|value| !value.is_empty())
})
}
fn joined_long_value(argument: &str) -> Option<&str> {
[
"--sysroot=",
"--target=",
"-fclang-abi-compat=",
"-fdebug-prefix-map=",
"-ffile-prefix-map=",
"-fmacro-prefix-map=",
"-fms-compatibility-version=",
"-fpack-struct=",
"-fvisibility=",
"-isysroot=",
"-mabi=",
"-march=",
"-mcpu=",
"-mfloat-abi=",
"-mfpu=",
"-miphoneos-version-min=",
"-mios-simulator-version-min=",
"-mmacosx-version-min=",
"-mtargetos=",
"-mtune=",
"-mtvos-version-min=",
"-mwatchos-version-min=",
"-std=",
"-stdlib=",
"-target=",
"-working-directory=",
"-x=",
]
.into_iter()
.find_map(|option| argument.strip_prefix(option))
}
fn require_nonempty(argument: &str, value: &str) -> Result<(), String> {
if value.is_empty() {
Err(format!("compiler argument has an empty value: {argument}"))
} else {
Ok(())
}
}
fn discard_without_value(argument: &str) -> bool {
matches!(
argument,
"-pedantic" | "-pedantic-errors" | "-Qunused-arguments" | "-w"
) || argument == "--coverage"
|| argument == "-pipe"
|| argument.starts_with("-O")
|| argument.starts_with("-R")
|| argument.starts_with("-W")
|| argument.starts_with("-f")
|| argument.starts_with("-g")
|| argument.starts_with("-m")
}
fn explicitly_forbidden(argument: &str) -> bool {
argument.starts_with('@')
|| matches!(
argument,
"--config"
| "--config-user-dir"
| "--config-system-dir"
| "-B"
| "-Wa"
| "-Wl"
| "-Wp"
| "-add-plugin"
| "-gcc-toolchain"
| "-load"
| "-mllvm"
| "-plugin"
)
|| [
"--config=",
"--config-user-dir=",
"--config-system-dir=",
"--gcc-toolchain",
"-B",
"-Wa,",
"-Wl,",
"-Wp,",
"-X",
"-fbuild-session-file",
"-fcrash-diagnostics-dir",
"-fcuda-include-gpubinary",
"-fembed-offload-object",
"-fimplicit-module",
"-fmemory-profile-use",
"-fmodule",
"-fpass-plugin",
"-fplugin",
"-fprebuilt-module-path",
"-fprofile-instr-use",
"-fprofile-list",
"-fprofile-remapping-file",
"-fprofile-sample-use",
"-fprofile-use",
"-fsanitize-blacklist",
"-fsanitize-coverage-allowlist",
"-fsanitize-coverage-ignorelist",
"-fsanitize-ignorelist",
"-fsanitize-system-ignorelist",
"-fsave-optimization-record",
"-fthinlto-index",
"-ftime-trace",
"-fxray-attr-list",
"-gcc-toolchain=",
]
.into_iter()
.any(|prefix| argument.starts_with(prefix))
}
pub(crate) struct Database {
pub(crate) root: PathBuf,
pub(crate) entries: Vec<Entry>,
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct Location {
file: PathBuf,
root: PathBuf,
}
#[derive(Default)]
pub(crate) struct Databases {
located: BTreeMap<PathBuf, Option<Location>>,
read: BTreeMap<Location, Result<Database, String>>,
}
impl Databases {
pub(crate) fn nearest(&mut self, path: &Path) -> Option<&Database> {
let start = if path.is_dir() { path } else { path.parent()? };
let location = self.locate(start)?;
let read = self
.read
.entry(location)
.or_insert_with_key(|location| Database::read(&location.file, &location.root));
match read {
Ok(database) => Some(database),
Err(why) => {
crate::refused(why);
None
}
}
}
fn locate(&mut self, start: &Path) -> Option<Location> {
if let Some(known) = self.located.get(start) {
return known.clone();
}
let found = search(start);
self.located.insert(start.to_path_buf(), found.clone());
found
}
}
fn search(start: &Path) -> Option<Location> {
for ancestor in start.ancestors() {
for location in LOCATIONS {
let candidate = ancestor.join(location);
if candidate.is_file() {
return Some(Location {
file: candidate,
root: ancestor.to_path_buf(),
});
}
}
}
None
}
impl Database {
fn read(path: &Path, root: &Path) -> Result<Self, String> {
let text = std::fs::read_to_string(path)
.map_err(|error| format!("reading {}: {error}", path.display()))?;
let raw: Vec<RecordedCommand> = serde_json::from_str(&text)
.map_err(|error| format!("parsing {}: {error}", path.display()))?;
Ok(Self {
root: canonical(root),
entries: raw.iter().filter_map(Recorded::entry).collect(),
})
}
pub(crate) fn unit(
&self,
unit: &str,
selector: Option<&CompileCommandSelector>,
) -> Option<&Entry> {
let named = Path::new(unit);
let absolute = canonical(named);
self.entries.iter().find(|entry| {
selector.is_none_or(|wanted| entry.selector.names_the_same_entry(wanted))
&& (Path::new(&codehelion_helper::ir::spell(Some(&self.root), &entry.file))
== named
|| entry.file == absolute)
})
}
pub(crate) fn definitions(&self) -> Vec<String> {
let mut all: Vec<String> = self
.entries
.iter()
.flat_map(|entry| entry.definitions.iter().cloned())
.collect();
all.sort();
all.dedup();
all
}
}
trait Recorded {
fn entry(&self) -> Option<Entry>;
}
impl Recorded for RecordedCommand {
fn entry(&self) -> Option<Entry> {
let directory = self.directory.as_ref().map(PathBuf::from);
let written = resolve(directory.as_deref(), Path::new(&self.file));
let words = self.words()?;
let file = canonical(&written);
let parsed_arguments = parse_arguments(&words, &written, directory.as_deref());
let arguments = ValidatedArguments::parse(&parsed_arguments);
Some(Entry {
file: file.clone(),
arguments,
definitions: definitions(&words),
selector: CompileCommandSelector {
file: file.display().to_string(),
directory: directory
.as_ref()
.map(|path| canonical(path).display().to_string()),
arguments: words,
},
})
}
}
pub(crate) fn canonical(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| lexical(path))
}
pub(crate) fn lexical(path: &Path) -> PathBuf {
let mut folded = PathBuf::new();
for part in path.components() {
match part {
Component::CurDir => {}
Component::ParentDir
if folded
.components()
.next_back()
.is_some_and(|last| matches!(last, Component::Normal(_))) =>
{
folded.pop();
}
other => folded.push(other),
}
}
folded
}
fn resolve(directory: Option<&Path>, path: &Path) -> PathBuf {
codehelion_helper_protocol::compile_commands::resolve_in_directory(directory, path)
}
fn parse_arguments(words: &[String], file: &Path, directory: Option<&Path>) -> Vec<String> {
let mut arguments = Vec::new();
if let Some(directory) = directory {
arguments.push(format!("-working-directory={}", directory.display()));
}
let mut index = compiler_words(words, file, directory);
while index < words.len() {
let word = words[index].as_str();
index += 1;
if resolve(directory, Path::new(word)) == file {
continue;
}
if DROPPED_WITH_VALUE.contains(&word) {
index += 1;
continue;
}
if DROPPED.contains(&word) || word.starts_with("-o") && word.len() > 2 {
continue;
}
arguments.push(word.to_string());
}
arguments
}
fn compiler_words(words: &[String], file: &Path, directory: Option<&Path>) -> usize {
words
.iter()
.position(|word| {
word.starts_with('-')
|| word.starts_with('@')
|| resolve(directory, Path::new(word.as_str())) == file
})
.unwrap_or(words.len())
}
const DROPPED: [&str; 5] = ["-c", "-MD", "-MMD", "-M", "-MM"];
const DROPPED_WITH_VALUE: [&str; 4] = ["-o", "-MF", "-MT", "-MQ"];
fn definitions(words: &[String]) -> Vec<String> {
let mut found = Vec::new();
let mut index = 0;
while index < words.len() {
let word = words[index].as_str();
index += 1;
for flag in ["-D", "-U"] {
if word == flag {
if let Some(value) = words.get(index) {
found.push(format!("{flag}{value}"));
index += 1;
}
} else if let Some(value) = word.strip_prefix(flag)
&& !value.is_empty()
{
found.push(format!("{flag}{value}"));
}
}
}
found
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests;