pub(crate) fn format_docstring(docstring: String) -> String {
let lines: Vec<&str> = docstring.lines().collect();
if lines.is_empty() {
return String::new();
}
let mut start = 0;
let mut end = lines.len();
while start < lines.len() && lines[start].trim().is_empty() {
start += 1;
}
while end > start && lines[end - 1].trim().is_empty() {
end -= 1;
}
if start >= end {
return String::new();
}
let lines = &lines[start..end];
let mut min_indent = usize::MAX;
for (i, line) in lines.iter().enumerate() {
if i == 0 && !line.trim().is_empty() {
continue; }
if !line.trim().is_empty() {
let indent = line.len() - line.trim_start().len();
min_indent = min_indent.min(indent);
}
}
if min_indent == usize::MAX {
min_indent = 0;
}
let mut result = Vec::new();
for (i, line) in lines.iter().enumerate() {
if i == 0 {
result.push(line.trim().to_string());
} else if line.trim().is_empty() {
result.push(String::new());
} else {
let dedented = if line.len() > min_indent {
&line[min_indent..]
} else {
line.trim_start()
};
result.push(dedented.to_string());
}
}
result.join("\n")
}
pub(crate) fn extract_word_at_position(line: &str, character: usize) -> Option<String> {
let char_indices: Vec<(usize, char)> = line.char_indices().collect();
if character >= char_indices.len() {
return None;
}
let (_byte_pos, c) = char_indices[character];
if !c.is_alphanumeric() && c != '_' {
return None;
}
let mut start_idx = character;
while start_idx > 0 {
let (_, prev_c) = char_indices[start_idx - 1];
if !prev_c.is_alphanumeric() && prev_c != '_' {
break;
}
start_idx -= 1;
}
let mut end_idx = character + 1;
while end_idx < char_indices.len() {
let (_, curr_c) = char_indices[end_idx];
if !curr_c.is_alphanumeric() && curr_c != '_' {
break;
}
end_idx += 1;
}
let start_byte = char_indices[start_idx].0;
let end_byte = if end_idx < char_indices.len() {
char_indices[end_idx].0
} else {
line.len()
};
Some(line[start_byte..end_byte].to_string())
}
pub(crate) fn find_function_name_position(
content: &str,
line: usize,
func_name: &str,
) -> (usize, usize) {
if let Some(line_content) = content.lines().nth(line.saturating_sub(1)) {
if let Some(def_pos) = line_content.find("def ") {
let after_def = &line_content[def_pos + 4..];
if let Some(name_pos) = after_def.find(func_name) {
let start_char = def_pos + 4 + name_pos;
let end_char = start_char + func_name.len();
return (start_char, end_char);
}
}
if let Some(pos) = line_content.find(func_name) {
return (pos, pos + func_name.len());
}
}
(0, func_name.len())
}
#[allow(dead_code)]
pub fn parameter_has_annotation(lines: &[&str], line: usize, end_char: usize) -> bool {
let line_idx = line.saturating_sub(1);
let Some(line_text) = lines.get(line_idx) else {
return false;
};
let after_param = if end_char < line_text.len() {
&line_text[end_char..]
} else {
return false;
};
let trimmed = after_param.trim_start();
trimmed.starts_with(':')
}
pub(crate) fn replace_identifier(text: &str, old: &str, new: &str) -> String {
let bytes = text.as_bytes();
let old_bytes = old.as_bytes();
let len = bytes.len();
let old_len = old_bytes.len();
let mut result = String::with_capacity(len + new.len());
let mut i = 0;
while i < len {
if i + old_len <= len && &bytes[i..i + old_len] == old_bytes {
let left_ok = i == 0
|| !(bytes[i - 1].is_ascii_alphanumeric()
|| bytes[i - 1] == b'_'
|| bytes[i - 1] == b'.');
let right_ok = i + old_len >= len
|| !(bytes[i + old_len].is_ascii_alphanumeric() || bytes[i + old_len] == b'_');
if left_ok && right_ok {
result.push_str(new);
i += old_len;
continue;
}
}
let ch_len = text[i..].chars().next().map_or(1, |c| c.len_utf8());
result.push_str(&text[i..i + ch_len]);
i += ch_len;
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_docstring_simple() {
let input = "Simple docstring".to_string();
assert_eq!(format_docstring(input), "Simple docstring");
}
#[test]
fn test_format_docstring_with_whitespace() {
let input = "\n\n Line 1\n Line 2\n\n".to_string();
assert_eq!(format_docstring(input), "Line 1\nLine 2");
}
#[test]
fn test_format_docstring_indented() {
let input = "First line\n Indented line\n Another indented".to_string();
let result = format_docstring(input);
assert_eq!(result, "First line\nIndented line\nAnother indented");
}
#[test]
fn test_extract_word_at_position() {
let line = "def my_function(arg1, arg2):";
assert_eq!(extract_word_at_position(line, 0), Some("def".to_string()));
assert_eq!(
extract_word_at_position(line, 4),
Some("my_function".to_string())
);
assert_eq!(extract_word_at_position(line, 16), Some("arg1".to_string()));
assert_eq!(extract_word_at_position(line, 20), None); }
#[test]
fn test_find_function_name_position() {
let content = "def my_function():\n pass";
let (start, end) = find_function_name_position(content, 1, "my_function");
assert_eq!(start, 4);
assert_eq!(end, 15);
}
#[test]
fn test_parameter_has_annotation_no_annotation() {
let lines: Vec<&str> = vec!["def test_example(my_fixture):"];
assert!(!parameter_has_annotation(&lines, 1, 27));
}
#[test]
fn test_parameter_has_annotation_with_annotation() {
let lines: Vec<&str> = vec!["def test_example(my_fixture: Database):"];
assert!(parameter_has_annotation(&lines, 1, 27));
}
#[test]
fn test_parameter_has_annotation_with_space_before_colon() {
let lines: Vec<&str> = vec!["def test_example(my_fixture : Database):"];
assert!(parameter_has_annotation(&lines, 1, 27));
}
#[test]
fn test_parameter_has_annotation_with_default_no_annotation() {
let lines: Vec<&str> = vec!["def test_example(my_fixture = None):"];
assert!(!parameter_has_annotation(&lines, 1, 27));
}
#[test]
fn test_parameter_has_annotation_with_default_and_annotation() {
let lines: Vec<&str> = vec!["def test_example(my_fixture: Database = None):"];
assert!(parameter_has_annotation(&lines, 1, 27));
}
#[test]
fn test_parameter_has_annotation_multiple_params_first() {
let lines: Vec<&str> = vec!["def test_example(fixture_a: TypeA, fixture_b):"];
assert!(parameter_has_annotation(&lines, 1, 26));
}
#[test]
fn test_parameter_has_annotation_multiple_params_second() {
let lines: Vec<&str> = vec!["def test_example(fixture_a: TypeA, fixture_b):"];
assert!(!parameter_has_annotation(&lines, 1, 44));
}
#[test]
fn test_parameter_has_annotation_multiline() {
let lines: Vec<&str> = vec![
"def test_example(",
" fixture_a: TypeA,",
" fixture_b,",
"):",
];
assert!(parameter_has_annotation(&lines, 2, 13));
assert!(!parameter_has_annotation(&lines, 3, 13));
}
#[test]
fn test_parameter_has_annotation_out_of_bounds() {
let lines: Vec<&str> = vec!["def test_example(my_fixture):"];
assert!(!parameter_has_annotation(&lines, 10, 27));
assert!(!parameter_has_annotation(&lines, 1, 100));
}
#[test]
fn test_parameter_has_annotation_empty_lines() {
let lines: Vec<&str> = vec![];
assert!(!parameter_has_annotation(&lines, 1, 0));
}
#[test]
fn test_replace_identifier_simple() {
assert_eq!(
replace_identifier("Path", "Path", "pathlib.Path"),
"pathlib.Path"
);
}
#[test]
fn test_replace_identifier_in_generic() {
assert_eq!(
replace_identifier("Optional[Path]", "Path", "pathlib.Path"),
"Optional[pathlib.Path]"
);
}
#[test]
fn test_replace_identifier_partial_no_match() {
assert_eq!(
replace_identifier("PathLike", "Path", "pathlib.Path"),
"PathLike"
);
}
#[test]
fn test_replace_identifier_as_word_suffix() {
assert_eq!(
replace_identifier("MyPath", "Path", "pathlib.Path"),
"MyPath"
);
}
#[test]
fn test_replace_identifier_repeated() {
assert_eq!(
replace_identifier("tuple[Path, Path]", "Path", "pathlib.Path"),
"tuple[pathlib.Path, pathlib.Path]"
);
}
#[test]
fn test_replace_identifier_union() {
assert_eq!(
replace_identifier("Path | None", "Path", "pathlib.Path"),
"pathlib.Path | None"
);
}
#[test]
fn test_replace_identifier_does_not_match_after_dot() {
assert_eq!(
replace_identifier("pathlib.Path", "Path", "xx.Path"),
"pathlib.Path"
);
}
}