use anyhow::Result;
use std::path::Path;
use crate::config::ToggleConfig;
use crate::exit_codes::UsageError;
pub fn supported_extensions() -> &'static [&'static str] {
&[
"py", "sh", "rb", "yaml", "yml", "toml", "r", "ex", "exs", "pl", "pm", "js", "jsx", "ts",
"tsx", "rs", "java", "c", "cpp", "go", "swift", "kt", "scala", "php", "lua", "hs", "sql",
]
}
#[derive(Debug, Clone)]
pub struct SectionInfo {
pub id: String,
pub desc: Option<String>,
pub start_line: usize, pub end_line: usize, }
pub struct SectionToggleResult {
pub modified: bool,
pub desc: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScanSectionInfo {
pub id: String,
pub group: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub variant: Option<String>,
pub file: String,
pub start_line: usize,
pub end_line: Option<usize>,
pub description: Option<String>,
pub state: String,
}
fn parse_section_desc(line: &str) -> Option<String> {
let marker = "desc=\"";
let start = line.find(marker)? + marker.len();
let rest = &line[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
fn parse_section_id(line: &str) -> Option<String> {
let marker = "ID=";
let start = line.find(marker)? + marker.len();
let rest = &line[start..];
let end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let id = &rest[..end];
if id.is_empty() {
None
} else {
Some(id.to_string())
}
}
pub fn parse_id_parts(id: &str) -> (String, Option<String>) {
match id.split_once(':') {
Some((g, v)) => (g.to_string(), Some(v.to_string())),
None => (id.to_string(), None),
}
}
fn line_matches_start(line: &str, section_id: &str) -> bool {
if !line.contains("toggle:start") {
return false;
}
parse_section_id(line).as_deref() == Some(section_id)
}
fn line_matches_end(line: &str, section_id: &str) -> bool {
if !line.contains("toggle:end") {
return false;
}
parse_section_id(line).as_deref() == Some(section_id)
}
pub fn discover_sections(content: &str) -> Vec<SectionInfo> {
let lines: Vec<&str> = content.lines().collect();
let mut sections = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].contains("toggle:start") {
if let Some(id) = parse_section_id(lines[i]) {
let desc = parse_section_desc(lines[i]);
let start_line = i + 1;
let mut end_line = None;
#[allow(clippy::needless_range_loop)]
for j in (i + 1)..lines.len() {
if line_matches_end(lines[j], &id) {
end_line = Some(j + 1); break;
}
}
if let Some(end_line) = end_line {
sections.push(SectionInfo {
id,
desc,
start_line,
end_line,
});
i = end_line; continue;
}
}
}
i += 1;
}
sections
}
pub fn discover_variants(content: &str, group: &str) -> Vec<SectionInfo> {
discover_sections(content)
.into_iter()
.filter(|s| parse_id_parts(&s.id).0 == group)
.collect()
}
pub fn scan_sections(path: &Path, content: &str) -> Vec<ScanSectionInfo> {
let lines: Vec<&str> = content.lines().collect();
let mut sections = Vec::new();
let file_str = path.display().to_string();
let comment_marker = get_comment_style(path, "auto", None)
.map(|cs| cs.single_line)
.unwrap_or_else(|_| "#".to_string());
let mut i = 0;
while i < lines.len() {
if let Some(_pos) = lines[i].find("toggle:start ID=") {
let id = parse_section_id(lines[i]).unwrap_or_default();
if id.is_empty() {
i += 1;
continue;
}
let description = parse_section_desc(lines[i]);
let start_line = i + 1;
let mut end_line = None;
#[allow(clippy::needless_range_loop)]
for j in (i + 1)..lines.len() {
if line_matches_end(lines[j], &id) {
end_line = Some(j + 1); break;
}
}
let state = if let Some(end) = end_line {
let content_start = i + 1;
let content_end = end - 1; detect_section_state(&lines[content_start..content_end], &comment_marker)
} else {
"unknown".to_string()
};
let (group, variant) = parse_id_parts(&id);
sections.push(ScanSectionInfo {
id,
group,
variant,
file: file_str.clone(),
start_line,
end_line,
description,
state,
});
if let Some(end) = end_line {
i = end;
} else {
i += 1;
}
} else {
i += 1;
}
}
sections
}
fn detect_section_state(lines: &[&str], comment_marker: &str) -> String {
let non_empty: Vec<&&str> = lines.iter().filter(|l| !l.trim().is_empty()).collect();
if non_empty.is_empty() {
return "empty".to_string();
}
let commented_count = non_empty
.iter()
.filter(|l| l.trim_start().starts_with(comment_marker))
.count();
if commented_count == non_empty.len() {
"commented".to_string()
} else if commented_count == 0 {
"uncommented".to_string()
} else {
"mixed".to_string()
}
}
#[derive(Debug, Clone)]
pub struct LineRange {
pub start: usize,
pub end: usize,
}
impl LineRange {
pub fn new(start: usize, end: usize) -> Self {
Self { start, end }
}
}
#[derive(Debug, Clone)]
pub struct CommentStyle {
pub single_line: String,
pub multi_line_start: Option<String>,
pub multi_line_end: Option<String>,
}
pub fn parse_line_range(range_spec: &str) -> Result<(usize, usize)> {
if let Some((start, end)) = range_spec.split_once(':') {
let start_line = start
.parse::<usize>()
.map_err(|_| UsageError(format!("Invalid start line: {}", start)))?;
if start_line == 0 {
return Err(UsageError("Start line must be >= 1, got 0".into()).into());
}
if let Some(stripped_end) = end.strip_prefix('+') {
let count = stripped_end
.parse::<usize>()
.map_err(|_| UsageError(format!("Invalid line count: {}", stripped_end)))?;
Ok((start_line, start_line + count))
} else {
let end_line = end
.parse::<usize>()
.map_err(|_| UsageError(format!("Invalid end line: {}", end)))?;
if end_line < start_line {
return Err(UsageError(format!(
"End line {} is less than start line {}",
end_line, start_line
))
.into());
}
Ok((start_line, end_line))
}
} else {
let line = range_spec
.parse::<usize>()
.map_err(|_| UsageError(format!("Invalid line number: {}", range_spec)))?;
if line == 0 {
return Err(UsageError("Line number must be >= 1, got 0".into()).into());
}
Ok((line, line))
}
}
pub fn merge_ranges(ranges: &[LineRange]) -> Vec<LineRange> {
if ranges.is_empty() {
return Vec::new();
}
let mut sorted: Vec<LineRange> = ranges.to_vec();
sorted.sort_by(|a, b| a.start.cmp(&b.start).then(a.end.cmp(&b.end)));
let mut merged = vec![sorted[0].clone()];
for range in &sorted[1..] {
let last = merged.last_mut().unwrap();
if range.start <= last.end + 1 {
last.end = last.end.max(range.end);
} else {
merged.push(range.clone());
}
}
merged
}
pub fn toggle_comments(content: &str, ranges: &[LineRange], force_mode: Option<&str>) -> String {
toggle_comments_with_marker(content, ranges, force_mode, "#")
}
pub fn toggle_comments_with_marker(
content: &str,
ranges: &[LineRange],
force_mode: Option<&str>,
marker: &str,
) -> String {
let protected = crate::io::detect_protected_lines(content);
toggle_comments_inner(content, ranges, force_mode, marker, &protected)
}
fn toggle_comments_inner(
content: &str,
ranges: &[LineRange],
force_mode: Option<&str>,
marker: &str,
protected: &[usize],
) -> String {
let mut lines: Vec<String> = content.lines().map(String::from).collect();
let merged = merge_ranges(ranges);
for range in &merged {
let start = range.start.saturating_sub(1);
let end = range.end.min(lines.len());
if start >= lines.len() {
continue;
}
let should_comment = match force_mode {
Some("on") => true,
Some("off") => false,
_ => {
let commented_count = lines[start..end]
.iter()
.enumerate()
.filter(|(i, line)| {
let abs_idx = start + i;
!protected.contains(&abs_idx) && !line.trim().is_empty()
})
.filter(|(_, line)| {
let trimmed = line.trim_start();
trimmed.starts_with(marker)
})
.count();
let total_non_empty = lines[start..end]
.iter()
.enumerate()
.filter(|(i, line)| {
let abs_idx = start + i;
!protected.contains(&abs_idx) && !line.trim().is_empty()
})
.count();
!(commented_count > 0 && commented_count == total_non_empty)
}
};
#[allow(clippy::needless_range_loop)]
for idx in start..end {
if protected.contains(&idx) {
continue;
}
let line = &lines[idx];
if line.trim().is_empty() {
continue;
}
let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect();
let rest = &line[leading_ws.len()..];
if should_comment {
let marker_space = format!("{} ", marker);
let stripped = if let Some(s) = rest.strip_prefix(&marker_space) {
s
} else if let Some(s) = rest.strip_prefix(marker) {
s
} else {
rest
};
lines[idx] = format!("{}{} {}", leading_ws, marker, stripped);
} else {
let marker_space = format!("{} ", marker);
if let Some(s) = rest.strip_prefix(&marker_space) {
lines[idx] = format!("{}{}", leading_ws, s);
} else if let Some(s) = rest.strip_prefix(marker) {
lines[idx] = format!("{}{}", leading_ws, s);
}
}
}
}
let mut result = lines.join("\n");
if content.ends_with('\n') {
result.push('\n');
}
result
}
pub fn toggle_comments_multi(
content: &str,
ranges: &[LineRange],
force_mode: Option<&str>,
start_delim: &str,
end_delim: &str,
) -> String {
let mut lines: Vec<String> = content.lines().map(String::from).collect();
let merged = merge_ranges(ranges);
for range in &merged {
let start = range.start.saturating_sub(1);
let end = range.end.min(lines.len());
if start >= lines.len() || start >= end {
continue;
}
let first_trimmed = lines[start].trim_start();
let last_trimmed = lines[end - 1].trim_end();
let is_commented =
first_trimmed.starts_with(start_delim) && last_trimmed.ends_with(end_delim);
let should_comment = match force_mode {
Some("on") => true,
Some("off") => false,
_ => !is_commented,
};
if should_comment && !is_commented {
let first_line = &lines[start];
let leading_ws: String = first_line
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let rest = &first_line[leading_ws.len()..];
lines[start] = format!("{}{} {}", leading_ws, start_delim, rest);
let last_line = &lines[end - 1];
lines[end - 1] = format!("{} {}", last_line, end_delim);
} else if !should_comment && is_commented {
let first_line = &lines[start];
let leading_ws: String = first_line
.chars()
.take_while(|c| c.is_whitespace())
.collect();
let rest = &first_line[leading_ws.len()..];
let stripped_start = if let Some(s) = rest.strip_prefix(start_delim) {
let s = s.strip_prefix(' ').unwrap_or(s);
format!("{}{}", leading_ws, s)
} else {
first_line.clone()
};
lines[start] = stripped_start;
let last_line = &lines[end - 1];
let stripped_end = if let Some(s) = last_line.strip_suffix(end_delim) {
let s = s.strip_suffix(' ').unwrap_or(s);
s.to_string()
} else {
last_line.clone()
};
lines[end - 1] = stripped_end;
}
}
let mut result = lines.join("\n");
if content.ends_with('\n') {
result.push('\n');
}
result
}
fn ext_to_language(ext: &str) -> &str {
match ext {
"py" => "python",
"js" | "jsx" => "javascript",
"ts" | "tsx" => "typescript",
"rs" => "rust",
"rb" => "ruby",
"sh" => "shell",
"yaml" | "yml" => "yaml",
"r" => "r",
"ex" | "exs" => "elixir",
"pl" | "pm" => "perl",
"java" => "java",
"c" => "c",
"cpp" => "cpp",
"go" => "go",
"swift" => "swift",
"kt" => "kotlin",
"scala" => "scala",
"php" => "php",
"lua" => "lua",
"hs" => "haskell",
"sql" => "sql",
"toml" => "toml",
other => other,
}
}
pub fn get_comment_style(
path: &Path,
_mode: &str,
config: Option<&ToggleConfig>,
) -> Result<CommentStyle> {
let extension = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
if let Some(cfg) = config {
let lang = ext_to_language(extension);
if let Some(delimiter) = cfg.get_language_delimiter(lang) {
let multi = cfg.get_language_multi_line_delimiters(lang);
return Ok(CommentStyle {
single_line: delimiter.to_string(),
multi_line_start: multi.map(|(s, _)| s.to_string()),
multi_line_end: multi.map(|(_, e)| e.to_string()),
});
}
if let Some(delimiter) = cfg
.global
.as_ref()
.and_then(|g| g.single_line_delimiter.as_deref())
{
let global = cfg.global.as_ref();
return Ok(CommentStyle {
single_line: delimiter.to_string(),
multi_line_start: global
.and_then(|g| g.multi_line_delimiter_start.as_deref())
.map(String::from),
multi_line_end: global
.and_then(|g| g.multi_line_delimiter_end.as_deref())
.map(String::from),
});
}
}
match extension {
"py" | "sh" | "rb" | "yaml" | "yml" | "toml" | "r" | "ex" | "exs" | "pl" | "pm" => {
Ok(CommentStyle {
single_line: "#".to_string(),
multi_line_start: None,
multi_line_end: None,
})
}
"js" | "jsx" | "ts" | "tsx" | "rs" | "java" | "c" | "cpp" | "go" | "swift" | "kt"
| "scala" | "php" => Ok(CommentStyle {
single_line: "//".to_string(),
multi_line_start: Some("/*".to_string()),
multi_line_end: Some("*/".to_string()),
}),
"lua" => Ok(CommentStyle {
single_line: "--".to_string(),
multi_line_start: Some("--[[".to_string()),
multi_line_end: Some("]]".to_string()),
}),
"hs" => Ok(CommentStyle {
single_line: "--".to_string(),
multi_line_start: Some("{-".to_string()),
multi_line_end: Some("-}".to_string()),
}),
"sql" => Ok(CommentStyle {
single_line: "--".to_string(),
multi_line_start: Some("/*".to_string()),
multi_line_end: Some("*/".to_string()),
}),
_ => Err(UsageError(format!(
"Unsupported file extension: .{}; use --comment-style or --config with a [global] single_line_delimiter",
extension
))
.into()),
}
}
pub fn find_and_toggle_section(
lines: &mut [String],
section_id: &str,
force: &Option<String>,
comment_style: &CommentStyle,
) -> Result<SectionToggleResult> {
let mut i = 0;
let mut modified = false;
let mut desc = None;
while i < lines.len() {
if line_matches_start(&lines[i], section_id) {
if desc.is_none() {
desc = parse_section_desc(&lines[i]);
}
let section_start = i + 1;
let mut section_end = None;
for (j, line) in lines.iter().enumerate().skip(i + 1) {
if line_matches_end(line, section_id) {
section_end = Some(j);
break;
}
}
let section_end = match section_end {
Some(end) => end,
None => {
return Err(UsageError(format!("Unclosed section ID={}", section_id)).into());
}
};
if section_end > section_start {
let force_mode = force.as_deref();
let section_content = lines[section_start..section_end].join("\n");
let range = LineRange::new(1, section_end - section_start);
let toggled = toggle_comments_inner(
§ion_content,
&[range],
force_mode,
&comment_style.single_line,
&[],
);
let mut toggled_lines: Vec<&str> = toggled.split('\n').collect();
if toggled_lines.last() == Some(&"") && toggled.ends_with('\n') {
toggled_lines.pop();
}
let section_len = section_end - section_start;
assert_eq!(
toggled_lines.len(),
section_len,
"Toggled line count ({}) must match section span ({})",
toggled_lines.len(),
section_len,
);
for (offset, new_line) in toggled_lines.iter().enumerate() {
if offset < section_len {
lines[section_start + offset] = (*new_line).to_string();
}
}
modified = true;
i = section_end;
}
}
i += 1;
}
Ok(SectionToggleResult { modified, desc })
}
pub fn toggle_variant_group(
content: &str,
group: &str,
force: &Option<String>,
comment_style: &CommentStyle,
) -> Result<String> {
let variants = discover_variants(content, group);
if variants.is_empty() {
return Err(UsageError(format!("no section or group '{group}' found")).into());
}
if force.is_none() && variants.len() >= 3 {
return Err(UsageError(format!(
"group '{group}' has {} variants; specify one with -S {group}:<name>",
variants.len()
))
.into());
}
let mut lines: Vec<String> = content.lines().map(String::from).collect();
for v in &variants {
find_and_toggle_section(&mut lines, &v.id, force, comment_style)?;
}
let mut joined = lines.join("\n");
if content.ends_with('\n') {
joined.push('\n');
}
Ok(joined)
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SectionType {
Solo,
Pair,
Group,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct GroupSummary {
pub group: String,
pub section_type: SectionType,
pub variant_count: usize,
pub file_count: usize,
pub state: String,
pub variants: Vec<String>,
}
pub fn summarize_scan(sections: &[ScanSectionInfo]) -> Vec<GroupSummary> {
use std::collections::{BTreeMap, BTreeSet};
let mut groups: BTreeMap<String, Vec<&ScanSectionInfo>> = BTreeMap::new();
for s in sections {
groups.entry(s.group.clone()).or_default().push(s);
}
groups
.into_iter()
.map(|(group, items)| {
let mut variants: Vec<String> =
items.iter().filter_map(|s| s.variant.clone()).collect();
variants.sort();
variants.dedup();
let section_type = match variants.len() {
0 | 1 => SectionType::Solo,
2 => SectionType::Pair,
_ => SectionType::Group,
};
let files: BTreeSet<&String> = items.iter().map(|s| &s.file).collect();
let states: BTreeSet<&String> = items.iter().map(|s| &s.state).collect();
let state = if states.len() == 1 {
states.into_iter().next().unwrap().clone()
} else {
"mixed".to_string()
};
GroupSummary {
group,
section_type,
variant_count: variants.len(),
file_count: files.len(),
state,
variants,
}
})
.collect()
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScanJsonFile {
pub path: String,
pub start: usize,
pub end: Option<usize>,
pub state: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub desc: Option<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScanJsonVariant {
pub id: String,
pub state: String,
pub files: Vec<ScanJsonFile>,
}
#[derive(Debug, Clone, serde::Serialize)]
#[serde(untagged)]
pub enum ScanJsonEntry {
Solo {
id: String,
#[serde(rename = "type")]
section_type: SectionType,
files: Vec<ScanJsonFile>,
},
Group {
group: String,
#[serde(rename = "type")]
section_type: SectionType,
variants: Vec<ScanJsonVariant>,
},
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ScanJsonRoot {
pub sections: Vec<ScanJsonEntry>,
}
pub fn build_scan_json(sections: &[ScanSectionInfo]) -> ScanJsonRoot {
use std::collections::BTreeMap;
let mut groups: BTreeMap<String, Vec<&ScanSectionInfo>> = BTreeMap::new();
for s in sections {
groups.entry(s.group.clone()).or_default().push(s);
}
let mut entries = Vec::new();
for (group, items) in groups {
let mut variant_ids: Vec<String> = items.iter().filter_map(|s| s.variant.clone()).collect();
variant_ids.sort();
variant_ids.dedup();
let section_type = match variant_ids.len() {
0 | 1 => SectionType::Solo,
2 => SectionType::Pair,
_ => SectionType::Group,
};
if matches!(section_type, SectionType::Solo) {
let files = items
.iter()
.map(|s| ScanJsonFile {
path: s.file.clone(),
start: s.start_line,
end: s.end_line,
state: s.state.clone(),
desc: s.description.clone(),
})
.collect();
entries.push(ScanJsonEntry::Solo {
id: group,
section_type,
files,
});
} else {
let mut by_id: BTreeMap<String, Vec<&ScanSectionInfo>> = BTreeMap::new();
for s in &items {
by_id.entry(s.id.clone()).or_default().push(s);
}
let variants = by_id
.into_iter()
.map(|(id, recs)| {
let state = recs[0].state.clone();
let files = recs
.iter()
.map(|s| ScanJsonFile {
path: s.file.clone(),
start: s.start_line,
end: s.end_line,
state: s.state.clone(),
desc: s.description.clone(),
})
.collect();
ScanJsonVariant { id, state, files }
})
.collect();
entries.push(ScanJsonEntry::Group {
group,
section_type,
variants,
});
}
}
ScanJsonRoot { sections: entries }
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckLevel {
Ok,
Warn,
Err,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct CheckIssue {
pub level: CheckLevel,
pub group: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub file: Option<String>,
pub message: String,
}
pub fn validate_sections(
per_file: &[(std::path::PathBuf, Vec<ScanSectionInfo>)],
pair_only: bool,
) -> Vec<CheckIssue> {
use std::collections::{BTreeSet, HashMap};
let mut issues = Vec::new();
for (path, sections) in per_file {
for s in sections {
if s.end_line.is_none() {
issues.push(CheckIssue {
level: CheckLevel::Err,
group: s.group.clone(),
file: Some(path.display().to_string()),
message: format!("unclosed marker for ID={}", s.id),
});
}
}
let mut counts: HashMap<&str, usize> = HashMap::new();
for s in sections {
*counts.entry(s.id.as_str()).or_insert(0) += 1;
}
for (id, n) in counts {
if n > 1 {
issues.push(CheckIssue {
level: CheckLevel::Err,
group: parse_id_parts(id).0,
file: Some(path.display().to_string()),
message: format!("duplicate section ID '{id}' ({n} occurrences)"),
});
}
}
}
let flat: Vec<ScanSectionInfo> = per_file.iter().flat_map(|(_, v)| v.clone()).collect();
let summaries = summarize_scan(&flat);
for sum in &summaries {
let group_is_pair_like = !matches!(sum.section_type, SectionType::Solo);
if pair_only && group_is_pair_like && sum.variant_count != 2 {
issues.push(CheckIssue {
level: CheckLevel::Warn,
group: sum.group.clone(),
file: None,
message: format!("{} variants, expected 2 (pair check)", sum.variant_count),
});
}
if matches!(sum.section_type, SectionType::Pair | SectionType::Group) {
for (path, sections) in per_file {
let present: BTreeSet<String> = sections
.iter()
.filter(|s| s.group == sum.group)
.filter_map(|s| s.variant.clone())
.collect();
if present.is_empty() {
continue;
}
let expected: BTreeSet<String> = sum.variants.iter().cloned().collect();
let missing: Vec<&String> = expected.difference(&present).collect();
if !missing.is_empty() {
issues.push(CheckIssue {
level: CheckLevel::Warn,
group: sum.group.clone(),
file: Some(path.display().to_string()),
message: format!(
"missing variant(s): {}",
missing
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
),
});
}
}
}
}
issues
}
pub fn activate_variant(
content: &str,
group: &str,
variant: &str,
comment_style: &CommentStyle,
) -> Result<String> {
let target_id = format!("{group}:{variant}");
let variants = discover_variants(content, group);
if !variants.iter().any(|s| s.id == target_id) {
return Err(UsageError(format!("variant '{target_id}' not found")).into());
}
let mut lines: Vec<String> = content.lines().map(String::from).collect();
for v in &variants {
let force = if v.id == target_id {
Some("off".to_string())
} else {
Some("on".to_string())
};
find_and_toggle_section(&mut lines, &v.id, &force, comment_style)?;
}
let mut joined = lines.join("\n");
if content.ends_with('\n') {
joined.push('\n');
}
Ok(joined)
}