pub mod language;
pub mod output;
pub mod types;
pub mod unicode;
pub mod walker;
use std::fs;
use std::path::Path;
pub const DEFAULT_EXCLUDES: &[&str] = &[
".linthis/**",
".git/**",
".hg/**",
".svn/**",
"node_modules/**",
"vendor/**",
"venv/**",
".venv/**",
"__pycache__/**",
"third_party/**",
"thirdparty/**",
"third-party/**",
"3rdparty/**",
"3rd_party/**",
"3rd-party/**",
"3party/**",
"external/**",
"externals/**",
"deps/**",
"target/**",
"build/**",
"dist/**",
"out/**",
"_build/**",
".idea/**",
".vscode/**",
".vs/**",
"*.swp",
"*~",
"*.generated.*",
"*.min.js",
"*.min.css",
".clang-format",
".clang-tidy",
"CPPLINT.cfg",
"compile_commands.json",
"ruff.toml",
".ruff.toml",
".flake8",
"setup.cfg",
"pyproject.toml",
".eslintrc",
".eslintrc.js",
".eslintrc.json",
".eslintrc.yml",
".eslintrc.yaml",
".prettierrc",
".prettierrc.json",
"prettierrc.js",
"prettier.config.js",
".golangci.yml",
".golangci.yaml",
"checkstyle.xml",
".checkstyle.xml",
"checkstyle-config.xml",
"detekt.yml",
".swiftlint.yml",
"analysis_options.yaml",
"scalastyle_config.xml",
".scalafix.conf",
".rubocop.yml",
"rustfmt.toml",
"Pods/**",
"**/Pods/**",
"Carthage/**",
"**/Carthage/**",
];
pub fn get_staged_files() -> crate::Result<Vec<std::path::PathBuf>> {
crate::vcs::detect_vcs().get_pending_files()
}
pub fn get_changed_files(base: Option<&str>) -> crate::Result<Vec<std::path::PathBuf>> {
crate::vcs::detect_vcs().get_changed_files(base)
}
pub fn get_uncommitted_files() -> crate::Result<Vec<std::path::PathBuf>> {
crate::vcs::detect_vcs().get_modified_files()
}
pub fn should_ignore(path: &Path, patterns: &[regex::Regex]) -> bool {
let path_str = path.to_string_lossy();
patterns.iter().any(|pattern| pattern.is_match(&path_str))
}
pub fn read_file_line(path: &Path, line_number: usize) -> Option<String> {
use std::fs::File;
use std::io::{BufRead, BufReader};
if line_number == 0 {
return None;
}
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
reader
.lines()
.nth(line_number - 1)
.and_then(|line| line.ok())
}
pub struct LineWithContext {
pub line: String,
pub before: Vec<(usize, String)>,
pub after: Vec<(usize, String)>,
}
pub fn read_file_line_with_context(
path: &Path,
line_number: usize,
context_lines: usize,
) -> Option<LineWithContext> {
use std::fs::File;
use std::io::{BufRead, BufReader};
if line_number == 0 {
return None;
}
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let start_line = line_number.saturating_sub(context_lines);
let end_line = line_number + context_lines;
let mut before = Vec::new();
let mut target_line = None;
let mut after = Vec::new();
for (idx, line_result) in reader.lines().enumerate() {
let current_line = idx + 1;
if current_line < start_line {
continue;
}
if current_line > end_line {
break;
}
if let Ok(content) = line_result {
if current_line < line_number {
before.push((current_line, content));
} else if current_line == line_number {
target_line = Some(content);
} else {
after.push((current_line, content));
}
}
}
target_line.map(|line| LineWithContext {
line,
before,
after,
})
}
pub fn get_project_root() -> std::path::PathBuf {
crate::vcs::detect_vcs().project_root().to_path_buf()
}
pub fn is_git_repo() -> bool {
crate::vcs::detect_vcs().kind() == crate::vcs::VcsKind::Git
}
pub fn is_vcs_repo() -> bool {
crate::vcs::detect_vcs().kind() != crate::vcs::VcsKind::None
}
pub fn parse_gitignore(gitignore_path: &Path) -> Vec<String> {
let mut patterns = Vec::new();
let content = match fs::read_to_string(gitignore_path) {
Ok(c) => c,
Err(_) => return patterns,
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.starts_with('!') {
continue;
}
let pattern = convert_gitignore_to_glob(line);
if !pattern.is_empty() {
patterns.push(pattern);
}
}
patterns
}
fn convert_gitignore_to_glob(pattern: &str) -> String {
let mut pattern = pattern.to_string();
let rooted = pattern.starts_with('/');
if rooted {
pattern = pattern[1..].to_string();
}
let is_dir = pattern.ends_with('/');
if is_dir {
pattern = pattern[..pattern.len() - 1].to_string();
}
if !rooted && !pattern.contains('/') {
pattern = format!("**/{}", pattern);
}
if is_dir || !pattern.contains('.') {
if !pattern.ends_with("/**") && !pattern.ends_with("/*") {
pattern.push_str("/**");
}
}
pattern
}
pub fn get_gitignore_patterns(project_root: &Path) -> Vec<String> {
let mut patterns = Vec::new();
let root_gitignore = project_root.join(".gitignore");
if root_gitignore.exists() {
patterns.extend(parse_gitignore(&root_gitignore));
}
if let Some(home) = std::env::var("HOME").ok().map(std::path::PathBuf::from) {
let global_gitignore = home.join(".gitignore_global");
if global_gitignore.exists() {
patterns.extend(parse_gitignore(&global_gitignore));
}
}
patterns
}
fn file_has_linthis_ignore(path: &Path) -> bool {
std::fs::read_to_string(path)
.map(|c| has_linthis_ignore(&c))
.unwrap_or(false)
}
fn resolve_global_gitignore_path() -> Option<std::path::PathBuf> {
let global_path = std::process::Command::new("git")
.args(["config", "--global", "core.excludesFile"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string());
global_path
.map(|p| {
if p.starts_with('~') {
if let Ok(home) = std::env::var("HOME") {
return std::path::PathBuf::from(p.replacen('~', &home, 1));
}
}
std::path::PathBuf::from(p)
})
.or_else(|| {
std::env::var("HOME")
.ok()
.map(|h| std::path::PathBuf::from(h).join(".gitignore_global"))
})
}
fn has_linthis_ignore(content: &str) -> bool {
content
.lines()
.any(|line| line.trim() == ".linthis/" || line.trim() == ".linthis")
}
pub fn ensure_gitignore_has_linthis(project_root: &Path) {
let gitignore_path = project_root.join(".gitignore");
if file_has_linthis_ignore(&gitignore_path) {
return;
}
let global_gitignore = resolve_global_gitignore_path();
if global_gitignore.as_ref().is_some_and(|p| file_has_linthis_ignore(p)) {
return;
}
let exclude_path = project_root.join(".git").join("info").join("exclude");
if file_has_linthis_ignore(&exclude_path) {
return;
}
let target_path = if let Some(ref gp) = global_gitignore {
if std::fs::read_to_string(gp).is_err() {
let _ = std::process::Command::new("git")
.args([
"config",
"--global",
"core.excludesFile",
&gp.to_string_lossy(),
])
.output();
}
gp.clone()
} else {
gitignore_path.clone()
};
let target_existing = std::fs::read_to_string(&target_path).unwrap_or_default();
let mut content = target_existing;
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
content.push_str("\n# Linthis working directory\n.linthis/\n");
match std::fs::write(&target_path, &content) {
Ok(_) => {
if target_path == gitignore_path {
eprintln!("[linthis] Added .linthis/ to .gitignore");
} else {
eprintln!(
"[linthis] Added .linthis/ to global gitignore ({})",
target_path.display()
);
}
}
Err(e) => {
eprintln!(
"[linthis] Warning: failed to update {}: {}",
target_path.display(),
e
);
eprintln!("[linthis] Please add '.linthis/' to your gitignore manually");
}
}
ensure_ide_exclude_linthis(project_root);
}
fn ensure_ide_exclude_linthis(project_root: &Path) {
ensure_jetbrains_exclude(project_root);
ensure_vscode_exclude(project_root);
}
fn ensure_jetbrains_exclude(project_root: &Path) {
let idea_dir = project_root.join(".idea");
if !idea_dir.is_dir() {
return;
}
let entries = match std::fs::read_dir(&idea_dir) {
Ok(e) => e,
Err(_) => return,
};
let exclude_url = "file://$MODULE_DIR$/.linthis";
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "iml") {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
if content.contains(exclude_url) {
continue;
}
let marker = "<content url=\"file://$MODULE_DIR$\"";
if let Some(pos) = content.find(marker) {
let after_marker = &content[pos..];
if let Some(close_pos) = after_marker.find("/>") {
let insert_at = pos + close_pos;
let new_content = format!(
"{}>\n <excludeFolder url=\"{}\" />\n </content{}",
&content[..insert_at],
exclude_url,
&content[insert_at + 2..] );
if std::fs::write(&path, &new_content).is_ok() {
eprintln!(
"[linthis] Added .linthis/ exclude to {}",
path.file_name().unwrap_or_default().to_string_lossy()
);
}
} else if let Some(close_pos) = after_marker.find('>') {
let content_start = pos + close_pos + 1;
let remaining = &content[content_start..];
if let Some(end_pos) = remaining.find("</content>") {
let insert_at = content_start + end_pos;
let new_content = format!(
"{} <excludeFolder url=\"{}\" />\n {}",
&content[..insert_at],
exclude_url,
&content[insert_at..]
);
if std::fs::write(&path, &new_content).is_ok() {
eprintln!(
"[linthis] Added .linthis/ exclude to {}",
path.file_name().unwrap_or_default().to_string_lossy()
);
}
}
}
}
}
}
fn ensure_vscode_exclude(project_root: &Path) {
if let Some(global_path) = vscode_user_settings_path() {
if let Ok(content) = std::fs::read_to_string(&global_path) {
if content.contains("\".linthis\"") || content.contains("\".linthis/\"") {
return;
}
}
}
let project_settings = project_root.join(".vscode").join("settings.json");
if let Ok(content) = std::fs::read_to_string(&project_settings) {
if content.contains("\".linthis\"") || content.contains("\".linthis/\"") {
return;
}
}
if let Some(global_path) = vscode_user_settings_path() {
if add_linthis_to_vscode_settings(&global_path) {
eprintln!(
"[linthis] Added .linthis/ to VS Code global search.exclude ({})",
global_path.display()
);
return;
}
}
if project_root.join(".vscode").is_dir()
&& add_linthis_to_vscode_settings(&project_settings)
{
eprintln!("[linthis] Added .linthis/ to .vscode/settings.json search.exclude");
}
}
fn vscode_user_settings_path() -> Option<std::path::PathBuf> {
if cfg!(target_os = "macos") {
std::env::var("HOME")
.ok()
.map(|h| Path::new(&h).join("Library/Application Support/Code/User/settings.json"))
} else if cfg!(target_os = "windows") {
std::env::var("APPDATA")
.ok()
.map(|a| Path::new(&a).join("Code/User/settings.json"))
} else {
std::env::var("HOME")
.ok()
.map(|h| Path::new(&h).join(".config/Code/User/settings.json"))
}
.filter(|p| p.parent().is_some_and(|d| d.is_dir()))
}
fn add_linthis_to_vscode_settings(settings_path: &Path) -> bool {
let content = std::fs::read_to_string(settings_path).unwrap_or_default();
if content.trim().is_empty() {
let new_content = "{\n \"search.exclude\": {\n \".linthis\": true\n }\n}\n";
return std::fs::write(settings_path, new_content).is_ok();
}
let mut json: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return false,
};
let search_exclude = json
.as_object_mut()
.and_then(|obj| {
if !obj.contains_key("search.exclude") {
obj.insert("search.exclude".to_string(), serde_json::json!({}));
}
obj.get_mut("search.exclude")
})
.and_then(|v| v.as_object_mut());
if let Some(exclude) = search_exclude {
exclude.insert(".linthis".to_string(), serde_json::json!(true));
} else {
return false;
}
serde_json::to_string_pretty(&json)
.map(|formatted| std::fs::write(settings_path, format!("{}\n", formatted)).is_ok())
.unwrap_or(false)
}