use crate::config::ResolvedConfig;
use anyhow::{Context, Result, anyhow};
use globset::{Glob, GlobSet, GlobSetBuilder};
use ignore::WalkBuilder;
use ocomment_core::{
DeclarativeProfile, Detection, Dialect, Language, TransformOptions, detect_language,
};
use std::{
env, fs,
path::{Path, PathBuf},
};
#[derive(Clone, Debug)]
pub struct SourceFile {
pub path: PathBuf,
pub source: Vec<u8>,
pub language: Language,
pub dialect: Dialect,
pub options: TransformOptions,
pub profile: Option<DeclarativeProfile>,
pub plugin: Option<String>,
}
#[derive(Clone, Debug)]
pub struct SkippedFile {
pub path: PathBuf,
pub reason: String,
pub error: bool,
pub explicit: bool,
}
#[derive(Default)]
pub struct Discovery {
pub files: Vec<SourceFile>,
pub skipped: Vec<SkippedFile>,
fatal: Option<anyhow::Error>,
}
pub const STDIN_PATH: &str = "<stdin>";
pub const STDIN_LANGUAGE_HELP: &str = "cannot detect the language of standard input; \
pass --language <LANGUAGE> (see `ocomment languages`)";
pub const NO_LANGUAGE: &str =
"no built-in language for this file (see `ocomment languages`; use --language to force)";
fn missing_path_reason() -> String {
env::current_dir().map_or_else(
|_| "path does not exist".to_owned(),
|cwd| {
format!(
"path does not exist (checked relative to {})",
cwd.display()
)
},
)
}
pub fn stdin_source(
bytes: Vec<u8>,
resolved: &ResolvedConfig,
forced_language: Option<Language>,
forced_dialect: Option<Dialect>,
) -> Result<SourceFile, SkippedFile> {
let skipped = |reason: &str, error: bool| SkippedFile {
path: PathBuf::from(STDIN_PATH),
reason: reason.to_owned(),
error,
explicit: true,
};
if bytes.iter().take(8192).any(|byte| *byte == 0) {
return Err(skipped("binary file (NUL byte)", false));
}
let detection = forced_language
.map(|language| Detection {
language,
dialect: forced_dialect.unwrap_or(Dialect::Standard),
reason: "command-line",
})
.or_else(|| detect_language(None, &bytes));
let Some(Detection {
language, dialect, ..
}) = detection
else {
return Err(skipped(STDIN_LANGUAGE_HELP, true));
};
let (language, options) = resolved
.for_path(
Path::new(STDIN_PATH),
language,
forced_dialect.unwrap_or(dialect),
)
.map_err(|error| skipped(&error.to_string(), true))?;
if forced_language.is_none() && !resolved.language_is_enabled(language) {
return Err(skipped("language disabled by configuration", false));
}
Ok(SourceFile {
path: PathBuf::from(STDIN_PATH),
source: bytes,
language,
dialect: options.scan.dialect,
options,
profile: None,
plugin: None,
})
}
pub const DEFAULT_TARGET: &str = ".";
const GIT_DIRECTORY: &str = ".git";
pub fn discover(
paths: &[PathBuf],
resolved: &ResolvedConfig,
forced_language: Option<Language>,
forced_dialect: Option<Dialect>,
) -> Result<Discovery> {
let implicit = [PathBuf::from(DEFAULT_TARGET)];
let (paths, explicit) = if paths.is_empty() {
(&implicit[..], false)
} else {
(paths, true)
};
discover_with_scope(paths, resolved, forced_language, forced_dialect, explicit)
}
pub fn discover_workspace(paths: &[PathBuf], resolved: &ResolvedConfig) -> Result<Discovery> {
discover_with_scope(paths, resolved, None, None, false)
}
fn discover_with_scope(
paths: &[PathBuf],
resolved: &ResolvedConfig,
forced_language: Option<Language>,
forced_dialect: Option<Dialect>,
explicit_arguments: bool,
) -> Result<Discovery> {
let include = compile_globs(&resolved.config.files.include)?;
let exclude = compile_globs(&resolved.config.files.exclude)?;
let mut discovery = Discovery::default();
let targets: Vec<_> = if paths.is_empty() {
vec![(resolved.root.clone(), false)]
} else {
paths
.iter()
.cloned()
.map(|path| (path, explicit_arguments))
.collect()
};
for (path, explicit_scope) in targets {
if path.is_file()
|| path
.symlink_metadata()
.is_ok_and(|metadata| metadata.file_type().is_symlink())
{
load_one(
&path,
explicit_scope,
explicit_scope,
resolved,
forced_language,
forced_dialect,
&include,
&exclude,
&mut discovery,
);
} else if path.is_dir() {
let mut builder = WalkBuilder::new(&path);
let ignore = resolved.config.files.ignore;
builder
.follow_links(resolved.config.files.follow_symlinks)
.standard_filters(ignore)
.hidden(!explicit_scope && !resolved.config.files.hidden)
.git_ignore(ignore)
.git_global(ignore)
.git_exclude(ignore)
.ignore(ignore)
.parents(ignore);
if ignore {
builder.add_custom_ignore_filename(".ocommentignore");
}
builder.filter_entry(|entry| entry.file_name() != GIT_DIRECTORY);
for entry in builder.build() {
match entry {
Ok(entry) if entry.file_type().is_some_and(|kind| kind.is_file()) => {
load_one(
entry.path(),
explicit_scope,
false,
resolved,
forced_language,
forced_dialect,
&include,
&exclude,
&mut discovery,
);
}
Ok(_) => {}
Err(error) => discovery.skipped.push(SkippedFile {
path: path.clone(),
reason: error.to_string(),
error: true,
explicit: explicit_scope,
}),
}
}
} else {
discovery.skipped.push(SkippedFile {
path,
reason: missing_path_reason(),
error: true,
explicit: explicit_scope,
});
}
}
if let Some(error) = discovery.fatal.take() {
return Err(error);
}
discovery
.files
.sort_by(|left, right| left.path.cmp(&right.path));
discovery
.files
.dedup_by(|left, right| left.path == right.path);
discovery.skipped.sort_by(|left, right| {
left.path
.cmp(&right.path)
.then(right.error.cmp(&left.error))
.then(right.explicit.cmp(&left.explicit))
});
discovery
.skipped
.dedup_by(|left, right| left.path == right.path);
Ok(discovery)
}
fn reported_path(path: &Path) -> PathBuf {
match path.strip_prefix(DEFAULT_TARGET) {
Ok(stripped) if !stripped.as_os_str().is_empty() => stripped.to_path_buf(),
_ => path.to_path_buf(),
}
}
#[allow(clippy::too_many_arguments)]
fn load_one(
path: &Path,
explicit_scope: bool,
explicit_path: bool,
resolved: &ResolvedConfig,
forced_language: Option<Language>,
forced_dialect: Option<Dialect>,
include: &GlobSet,
exclude: &GlobSet,
discovery: &mut Discovery,
) {
let path = &reported_path(path);
let relative = resolved.relative_to_root(path);
if (!include.is_empty() && !include.is_match(&relative)) || exclude.is_match(&relative) {
return;
}
let link_metadata = match path.symlink_metadata() {
Ok(value) => value,
Err(error) => {
discovery.skipped.push(skip(path, explicit_path, error));
return;
}
};
let metadata = if link_metadata.file_type().is_symlink() {
if !resolved.config.files.follow_symlinks {
discovery.skipped.push(SkippedFile {
path: path.to_path_buf(),
reason: "symbolic link".into(),
error: false,
explicit: explicit_path,
});
return;
}
match path.metadata() {
Ok(metadata) if metadata.is_file() => metadata,
Ok(_) => return,
Err(error) => {
discovery.skipped.push(skip(path, explicit_path, error));
return;
}
}
} else {
link_metadata
};
if !explicit_scope && metadata.len() > resolved.config.files.max_size {
discovery.skipped.push(SkippedFile {
path: path.to_path_buf(),
reason: format!("larger than {} bytes", resolved.config.files.max_size),
error: false,
explicit: explicit_path,
});
return;
}
let source = match fs::read(path) {
Ok(value) => value,
Err(error) => {
discovery.skipped.push(skip(path, explicit_path, error));
return;
}
};
if source.iter().take(8192).any(|byte| *byte == 0) {
discovery.skipped.push(SkippedFile {
path: path.to_path_buf(),
reason: "binary file (NUL byte)".into(),
error: false,
explicit: explicit_path,
});
return;
}
let built_in = forced_language
.map(|language| Detection {
language,
dialect: forced_dialect.unwrap_or(Dialect::Standard),
reason: "command-line",
})
.or_else(|| detect_language(Some(path), &source));
let Detection {
language: detected_language,
dialect: detected_dialect,
..
} = built_in.unwrap_or(Detection {
language: Language::Unknown,
dialect: Dialect::Standard,
reason: "configuration-routing",
});
let (language, options) = match resolved.for_path(path, detected_language, detected_dialect) {
Ok(value) => value,
Err(error) => {
if discovery.fatal.is_none() {
discovery.fatal = Some(error);
}
return;
}
};
if !resolved.language_is_enabled(language) {
discovery.skipped.push(SkippedFile {
path: path.to_path_buf(),
reason: "language disabled by configuration".into(),
error: false,
explicit: explicit_path,
});
return;
}
let profile = if language == Language::Unknown {
profile_for_path(path, resolved)
} else {
None
};
let plugin = if language == Language::Unknown && profile.is_none() {
plugin_for_path(path, resolved)
} else {
None
};
if language == Language::Unknown && profile.is_none() && plugin.is_none() {
discovery.skipped.push(SkippedFile {
path: path.to_path_buf(),
reason: NO_LANGUAGE.into(),
error: false,
explicit: explicit_path,
});
return;
}
discovery.files.push(SourceFile {
path: path.to_path_buf(),
source,
language,
dialect: options.scan.dialect,
options,
profile,
plugin,
});
}
pub fn plugin_for_path(path: &Path, resolved: &ResolvedConfig) -> Option<String> {
let extension = path.extension()?.to_str()?.trim_start_matches('.');
resolved
.config
.plugins
.routes
.get(&extension.to_ascii_lowercase())
.cloned()
}
pub fn profile_for_path(path: &Path, resolved: &ResolvedConfig) -> Option<DeclarativeProfile> {
let extension = path.extension()?.to_str()?.trim_start_matches('.');
resolved
.config
.profiles
.values()
.find(|profile| {
profile.extensions.iter().any(|candidate| {
candidate
.trim_start_matches('.')
.eq_ignore_ascii_case(extension)
})
})
.cloned()
}
pub(crate) fn compile_globs(patterns: &[String]) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
let glob = Glob::new(pattern).map_err(|error| {
anyhow!(
"invalid file glob `{}`: {}",
crate::output::sanitize_path(pattern),
crate::output::sanitize_message(&error.to_string())
)
})?;
builder.add(glob);
}
builder.build().context("cannot compile file globs")
}
fn skip(path: &Path, explicit: bool, error: impl std::fmt::Display) -> SkippedFile {
SkippedFile {
path: path.to_path_buf(),
reason: error.to_string(),
error: true,
explicit,
}
}