pub(crate) mod json;
pub(crate) mod minimal;
pub(crate) mod pseudo;
pub(crate) mod signatures;
pub(crate) mod structure;
pub(crate) mod toml;
pub(crate) mod truncate;
pub(crate) mod types;
pub(crate) mod utils;
pub(crate) mod yaml;
use crate::{Language, Mode, Result, TransformConfig};
use tree_sitter::Tree;
use truncate::NodeSpan;
type TransformOutput = (String, Vec<NodeSpan>);
pub(crate) fn transform_tree(
source: &str,
tree: &Tree,
language: Language,
config: &TransformConfig,
) -> Result<String> {
let (text, spans) = transform_tree_with_spans(source, tree, language, config)?;
if let Some(max_lines) = config.max_lines {
truncate::truncate_to_lines(&text, &spans, language, max_lines)
} else {
Ok(text)
}
}
fn transform_tree_with_spans(
source: &str,
tree: &Tree,
language: Language,
config: &TransformConfig,
) -> Result<TransformOutput> {
match config.mode {
Mode::Structure => {
structure::transform_structure_with_spans(source, tree, language, config)
}
Mode::Signatures => {
signatures::transform_signatures_with_spans(source, tree, language, config)
}
Mode::Types => types::transform_types_with_spans(source, tree, language, config),
Mode::Pseudo => pseudo::transform_pseudo_with_spans(source, tree, language, config),
Mode::Full => {
let text = source.to_string();
let line_count = text.lines().count();
let spans = vec![NodeSpan::new(0..line_count, "source_file")];
Ok((text, spans))
}
Mode::Minimal => {
let text = minimal::transform_minimal(source, tree, language, config)?;
let line_count = text.lines().count();
let spans = vec![NodeSpan::new(0..line_count, "source_file")];
Ok((text, spans))
}
}
}
pub(crate) fn transform_tree_with_line_map(
source: &str,
tree: &Tree,
language: Language,
config: &TransformConfig,
) -> Result<(String, Option<Vec<usize>>)> {
if !config.line_numbers {
let text = transform_tree(source, tree, language, config)?;
return Ok((text, None));
}
let (text, spans, line_map) = match config.mode {
Mode::Structure => {
structure::transform_structure_with_spans_and_line_map(source, tree, language, config)?
}
Mode::Signatures => signatures::transform_signatures_with_spans_and_line_map(
source, tree, language, config,
)?,
Mode::Types => {
types::transform_types_with_spans_and_line_map(source, tree, language, config)?
}
Mode::Full => {
let text = source.to_string();
let line_count = text.lines().count();
let spans = vec![NodeSpan::new(0..line_count, "source_file")];
let line_map: Vec<usize> = (1..=line_count).collect();
(text, spans, line_map)
}
Mode::Minimal => {
let text = minimal::transform_minimal(source, tree, language, config)?;
let line_count = text.lines().count();
let spans = vec![NodeSpan::new(0..line_count, "source_file")];
let line_map = compute_line_map_by_text_matching(source, &text);
(text, spans, line_map)
}
Mode::Pseudo => {
pseudo::transform_pseudo_with_spans_and_line_map(source, tree, language, config)?
}
};
let (final_text, final_line_map) = if let Some(max_lines) = config.max_lines {
let truncated_text = truncate::truncate_to_lines(&text, &spans, language, max_lines)?;
let final_line_map = reconcile_line_map_after_truncation(&text, &truncated_text, &line_map);
(truncated_text, final_line_map)
} else {
(text, line_map)
};
Ok((final_text, Some(final_line_map)))
}
pub(crate) fn compute_line_starts(bytes: &[u8]) -> Vec<usize> {
std::iter::once(0)
.chain(bytes.iter().enumerate().filter_map(
|(i, &b)| {
if b == b'\n' {
Some(i + 1)
} else {
None
}
},
))
.collect()
}
pub(crate) fn compute_line_map_by_text_matching(source: &str, output: &str) -> Vec<usize> {
let source_lines: Vec<&str> = source.lines().collect();
let output_lines: Vec<&str> = output.lines().collect();
let mut source_pos = 0usize;
let mut result = Vec::with_capacity(output_lines.len());
for output_line in &output_lines {
let mut found = false;
for (offset, source_line) in source_lines[source_pos..].iter().enumerate() {
if *source_line == *output_line {
let source_line_num = source_pos + offset + 1; result.push(source_line_num);
source_pos += offset + 1;
found = true;
break;
}
}
if !found {
result.push(0);
}
}
result
}
pub(crate) fn compute_line_map_from_removed_ranges(
source: &str,
ranges: &[(usize, usize)],
) -> Vec<usize> {
let source_bytes = source.as_bytes();
let total_bytes = source.len();
let line_starts: Vec<usize> = compute_line_starts(source_bytes);
let byte_to_line = |pos: usize| -> usize {
match line_starts.binary_search(&pos) {
Ok(idx) => idx + 1,
Err(idx) => idx.max(1), }
};
let mut result: Vec<usize> = Vec::new();
let mut current_output_source_line: Option<usize> = None;
let mut range_idx = 0usize;
let mut pos = 0usize;
while pos < total_bytes {
while range_idx < ranges.len() && pos >= ranges[range_idx].0 {
let range_end = ranges[range_idx].1;
range_idx += 1;
if range_end > pos {
pos = range_end;
}
}
if pos >= total_bytes {
break;
}
let byte = source_bytes[pos];
let src_line = byte_to_line(pos);
if current_output_source_line.is_none() {
current_output_source_line = Some(src_line);
}
if byte == b'\n' {
result.push(current_output_source_line.unwrap_or(src_line));
current_output_source_line = None;
}
pos += 1;
}
if let Some(src_line) = current_output_source_line {
result.push(src_line);
}
result
}
pub(crate) fn normalize_line_map_blanks(
pre_normalized_text: &str,
line_map: Vec<usize>,
) -> Vec<usize> {
let mut result = Vec::with_capacity(line_map.len());
let mut consecutive_blanks: usize = 0;
for (line, &src_line) in pre_normalized_text.lines().zip(line_map.iter()) {
let trimmed = line.trim_end();
if trimmed.is_empty() {
consecutive_blanks += 1;
if consecutive_blanks > 2 {
continue;
}
} else {
consecutive_blanks = 0;
}
result.push(src_line);
}
result
}
pub(crate) fn reconcile_line_map_after_truncation(
pre_trunc_text: &str,
truncated_text: &str,
pre_trunc_line_map: &[usize],
) -> Vec<usize> {
let pre_lines: Vec<&str> = pre_trunc_text.lines().collect();
let trunc_lines: Vec<&str> = truncated_text.lines().collect();
let mut result = Vec::with_capacity(trunc_lines.len());
let mut cursor = 0usize;
for trunc_line in &trunc_lines {
let tail = &pre_lines[cursor..];
if let Some(offset) = tail.iter().position(|pre| pre == trunc_line) {
let abs_idx = cursor + offset;
let source_line = pre_trunc_line_map.get(abs_idx).copied().unwrap_or(0);
result.push(source_line);
cursor = abs_idx + 1; } else {
result.push(0);
}
}
result
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::*;
#[test]
fn test_text_matching_identity() {
let source = "line 1\nline 2\nline 3\n";
let output = "line 1\nline 2\nline 3\n";
let map = compute_line_map_by_text_matching(source, output);
assert_eq!(map, vec![1, 2, 3]);
}
#[test]
fn test_text_matching_skipped_lines() {
let source = "aaa\nbbb\nccc\n";
let output = "aaa\nccc\n";
let map = compute_line_map_by_text_matching(source, output);
assert_eq!(map, vec![1, 3]);
}
#[test]
fn test_text_matching_unmatched_line() {
let source = "aaa\nbbb\n";
let output = "aaa\n// ...\nbbb\n";
let map = compute_line_map_by_text_matching(source, output);
assert_eq!(map, vec![1, 0, 2]);
}
#[test]
fn test_text_matching_empty() {
let map = compute_line_map_by_text_matching("", "");
assert!(map.is_empty());
}
#[test]
fn test_text_matching_duplicate_lines() {
let source = "x\nx\nx\n";
let output = "x\nx\n";
let map = compute_line_map_by_text_matching(source, output);
assert_eq!(map, vec![1, 2]);
}
#[test]
fn test_reconcile_identity() {
let pre = "aaa\nbbb\nccc\n";
let trunc = "aaa\nbbb\nccc\n";
let pre_map = vec![1, 5, 10];
let result = reconcile_line_map_after_truncation(pre, trunc, &pre_map);
assert_eq!(result, vec![1, 5, 10]);
}
#[test]
fn test_reconcile_with_dropped_line() {
let pre = "aaa\nbbb\nccc\n";
let trunc = "aaa\nccc\n";
let pre_map = vec![1, 5, 10];
let result = reconcile_line_map_after_truncation(pre, trunc, &pre_map);
assert_eq!(result, vec![1, 10]);
}
#[test]
fn test_reconcile_with_omission_marker() {
let pre = "aaa\nbbb\nccc\n";
let trunc = "aaa\n/* ... */\nccc\n";
let pre_map = vec![1, 5, 10];
let result = reconcile_line_map_after_truncation(pre, trunc, &pre_map);
assert_eq!(result, vec![1, 0, 10]);
}
#[test]
fn test_reconcile_empty() {
let result = reconcile_line_map_after_truncation("", "", &[]);
assert!(result.is_empty());
}
#[test]
fn test_reconcile_duplicate_lines_tail_bias() {
let pre = "a\nb\n}\nc\n}\nd\n}\n";
let pre_map = vec![1, 2, 3, 4, 5, 6, 7];
let trunc = "/* ... */\nc\n}\nd\n}\n";
let result = reconcile_line_map_after_truncation(pre, trunc, &pre_map);
assert_eq!(result, vec![0, 4, 5, 6, 7]);
}
#[test]
fn test_reconcile_omission_marker_does_not_advance_cursor() {
let pre = "x\ny\nz\n";
let pre_map = vec![10, 20, 30];
let trunc = "/* ... */\ny\nz\n";
let result = reconcile_line_map_after_truncation(pre, trunc, &pre_map);
assert_eq!(result, vec![0, 20, 30]);
}
#[test]
fn test_from_ranges_identity_no_ranges() {
let source = "aaa\nbbb\nccc\n";
let map = compute_line_map_from_removed_ranges(source, &[]);
assert_eq!(map, vec![1, 2, 3]);
}
#[test]
fn test_from_ranges_whole_line_removed() {
let source = "aaa\nbbb\nccc\n";
let ranges = [(4, 8)]; let map = compute_line_map_from_removed_ranges(source, &ranges);
assert_eq!(map, vec![1, 3]);
}
#[test]
fn test_from_ranges_inline_range_removed() {
let source = "def foo(a: int):\n pass\n";
let colon_int = source.find(": int").unwrap();
let ranges = [(colon_int, colon_int + ": int".len())];
let map = compute_line_map_from_removed_ranges(source, &ranges);
assert_eq!(map, vec![1, 2]);
}
#[test]
fn test_from_ranges_modified_def_line_maps_to_correct_source_line() {
let source = "def foo(a: int) -> str:\n return str(a)\ndef bar(b: str) -> int:\n return len(b)\n";
let a_end = 9usize; let colon_int_end = a_end + ": int".len(); let arrow_end = colon_int_end + " -> str".len(); let ranges = [(a_end, colon_int_end), (colon_int_end, arrow_end)];
let map = compute_line_map_from_removed_ranges(source, &ranges);
assert_eq!(
map[0], 1,
"Modified def line must map to source line 1, not 0. Got map: {:?}",
map
);
assert_eq!(
map[1], 2,
"return str(a) must map to source line 2. Got map: {:?}",
map
);
}
#[test]
fn test_from_ranges_empty_source() {
let map = compute_line_map_from_removed_ranges("", &[]);
assert!(map.is_empty());
}
#[test]
fn test_from_ranges_no_trailing_newline() {
let source = "aaa\nbbb";
let map = compute_line_map_from_removed_ranges(source, &[]);
assert_eq!(map, vec![1, 2]);
}
#[test]
fn test_normalize_line_map_no_excess_blanks() {
let text = "a\n\nb\n";
let line_map = vec![1, 2, 3];
let result = normalize_line_map_blanks(text, line_map.clone());
assert_eq!(result, line_map);
}
#[test]
fn test_normalize_line_map_drops_third_blank() {
let text = "a\n\n\n\nb\n";
let line_map = vec![1, 2, 3, 4, 5];
let result = normalize_line_map_blanks(text, line_map);
assert_eq!(result, vec![1, 2, 3, 5]);
}
#[test]
fn test_normalize_line_map_empty() {
let result = normalize_line_map_blanks("", vec![]);
assert!(result.is_empty());
}
#[test]
fn test_from_ranges_newline_byte_boundary() {
let source = "ab\ncd\n";
let ranges = [(2usize, 3usize)];
let map = compute_line_map_from_removed_ranges(source, &ranges);
assert_eq!(
map,
vec![1],
"Joining two lines by removing only the newline must map output line to source line 1, got {:?}",
map
);
}
}