use crate::position::Position;
#[must_use]
pub fn line_at(content: &str, position: Position) -> Option<&str> {
content.lines().nth(position.line as usize)
}
#[must_use]
pub fn raw_prefix(line: &str, character: u32) -> &str {
let prefix_end =
crate::lsp_helpers::utf16_to_byte_offset(line, character).unwrap_or(line.len());
debug_assert!(
line.is_char_boundary(prefix_end),
"prefix_end must be a char boundary"
);
line.get(..prefix_end).unwrap_or(line).trim()
}
#[must_use]
pub fn is_in_toml_dependencies(content: &str, line_number: usize) -> bool {
let lines: Vec<_> = content.lines().enumerate().take(line_number + 1).collect();
for (_, line) in lines.iter().rev() {
let line = line.trim();
if line.starts_with('[') && line.ends_with(']') {
return line == "[dependencies]"
|| line == "[dev-dependencies]"
|| line == "[build-dependencies]"
|| line == "[workspace.dependencies]"
|| line == "[project.dependencies]"
|| line == "[project.optional-dependencies]"
|| line.starts_with("[target.")
&& (line.contains(".dependencies]")
|| line.contains(".dev-dependencies]")
|| line.contains(".build-dependencies]"));
}
}
false
}
#[must_use]
pub fn is_in_json_dependencies(content: &str, line_number: usize, keys: &[&str]) -> bool {
let mut in_dependencies = false;
let mut brace_depth = 0;
let needles: Vec<String> = keys.iter().map(|key| format!("\"{key}\":")).collect();
for (i, line) in content.lines().enumerate() {
if i > line_number {
break;
}
let trimmed = line.trim();
if trimmed.starts_with('"')
&& needles
.iter()
.any(|needle| trimmed.contains(needle.as_str()))
{
in_dependencies = true;
brace_depth = 0;
}
if in_dependencies {
for ch in trimmed.chars() {
match ch {
'{' => brace_depth += 1,
'}' => {
brace_depth -= 1;
if brace_depth <= 0 {
in_dependencies = false;
}
}
_ => {}
}
}
if i == line_number && in_dependencies && brace_depth > 0 {
return true;
}
}
}
false
}
#[must_use]
pub fn is_in_xml_tag_section(content: &str, line_number: usize, tag: &str) -> bool {
let open_prefix = format!("<{tag}");
let close = format!("</{tag}>");
let mut depth: usize = 0;
for (i, line) in content.lines().enumerate() {
if i > line_number {
break;
}
let opens_here = count_open_tags(line, &open_prefix);
depth += opens_here;
if i == line_number && opens_here > 0 {
return true;
}
depth = depth.saturating_sub(line.matches(close.as_str()).count());
if i == line_number && depth > 0 {
return true;
}
}
false
}
#[expect(
clippy::string_slice,
reason = "search_from only ever advances to idx + open_prefix.len(), always a char boundary"
)]
fn count_open_tags(line: &str, open_prefix: &str) -> usize {
let mut count = 0;
let mut search_from = 0;
while let Some(rel_idx) = line[search_from..].find(open_prefix) {
let idx = search_from + rel_idx;
let after = &line[idx + open_prefix.len()..];
if after.starts_with('>') || after.starts_with(char::is_whitespace) {
count += 1;
}
search_from = idx + open_prefix.len();
}
count
}
#[must_use]
pub fn strip_leading_xml_tag(prefix: &str) -> (&str, Option<&str>) {
#[expect(
clippy::string_slice,
reason = ">/< are single-byte ASCII chars, so gt + 1/lt + 1 are always valid char \
boundaries"
)]
{
let Some(gt) = prefix.rfind('>') else {
return (prefix, None);
};
let stripped = &prefix[gt + 1..];
let Some(lt) = prefix[..gt].rfind('<') else {
return (stripped, None);
};
let name = prefix[lt + 1..gt]
.split(|c: char| c == '/' || c.is_whitespace())
.next()
.unwrap_or("");
if name.is_empty() {
(stripped, None)
} else {
(stripped, Some(name))
}
}
}
#[must_use]
pub fn strip_open_xml_attribute_value<'a>(
prefix: &'a str,
elements: &[&str],
attrs: &[&str],
) -> &'a str {
let mut quote: Option<char> = None;
let mut value_start = 0usize;
let mut in_target_attr = false;
let mut segment_start = 0usize;
let mut element = "";
for (idx, ch) in prefix.char_indices() {
if let Some(open) = quote {
if ch == open {
quote = None;
segment_start = idx + ch.len_utf8();
}
continue;
}
if ch == '<' {
segment_start = idx + ch.len_utf8();
#[expect(
clippy::string_slice,
reason = "char-boundary safe: idx + ch.len_utf8() always lands on one"
)]
let rest = &prefix[segment_start..];
element = rest
.split(|c: char| c == '>' || c == '/' || c.is_whitespace())
.next()
.unwrap_or("");
continue;
}
if ch == '"' || ch == '\'' {
#[expect(
clippy::string_slice,
reason = "char-boundary safe: idx comes from char_indices"
)]
let before_quote = prefix[segment_start..idx].trim_end();
let name = before_quote
.strip_suffix('=')
.unwrap_or(before_quote)
.split_whitespace()
.next_back()
.unwrap_or("");
in_target_attr = elements.contains(&element) && attrs.contains(&name);
quote = Some(ch);
value_start = idx + ch.len_utf8();
}
}
if quote.is_some() && in_target_attr {
#[expect(
clippy::string_slice,
reason = "char-boundary safe: value_start is idx + ch.len_utf8() for the opening \
quote"
)]
let value = &prefix[value_start..];
return value.split(['"', '\'']).next().unwrap_or(value);
}
""
}
#[must_use]
pub fn count_real_quotes(segment: &str) -> (usize, Option<usize>) {
count_real_quotes_with(segment, '"')
}
#[must_use]
pub fn count_real_quotes_with(segment: &str, quote: char) -> (usize, Option<usize>) {
let mut count = 0usize;
let mut last_real_quote = None;
for idx in real_quote_indices(segment, quote) {
count += 1;
last_real_quote = Some(idx);
}
(count, last_real_quote)
}
#[must_use]
pub fn find_closing_quote(segment: &str, quote: char) -> Option<usize> {
real_quote_indices(segment, quote).next()
}
fn real_quote_indices(segment: &str, quote: char) -> impl Iterator<Item = usize> + '_ {
let mut backslash_run = 0usize;
segment
.char_indices()
.filter_map(move |(idx, ch)| match ch {
'\\' => {
backslash_run += 1;
None
}
ch if ch == quote => {
let is_real = backslash_run.is_multiple_of(2);
backslash_run = 0;
is_real.then_some(idx)
}
_ => {
backslash_run = 0;
None
}
})
}
#[must_use]
pub fn strip_open_json_key(prefix: &str) -> (&str, bool) {
let (quote_count, last_quote) = count_real_quotes(prefix);
if quote_count == 0 {
return (prefix, false);
}
if quote_count.is_multiple_of(2) {
return ("", false);
}
let Some(last_quote) = last_quote else {
return ("", false);
};
#[expect(
clippy::string_slice,
reason = "char-boundary safe: \" is single-byte ASCII, so last_quote + 1 always lands \
on one"
)]
let (before_quote, after_quote) = (&prefix[..last_quote], &prefix[last_quote + 1..]);
if before_quote.trim_end().ends_with(':') {
("", false)
} else {
(after_quote, true)
}
}
#[must_use]
pub fn open_quoted_tail(segment: &str) -> Option<&str> {
let (count, last_quote) = count_real_quotes(segment);
if count.is_multiple_of(2) {
return None;
}
#[expect(
clippy::string_slice,
reason = "char-boundary safe: \" is single-byte ASCII, so last_quote + 1 always lands \
on one"
)]
last_quote.map(|pos| &segment[pos + 1..])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::position::Position;
#[test]
fn test_raw_prefix_cursor_beyond_line() {
let line = "serde";
assert_eq!(raw_prefix(line, 100), "serde");
}
#[expect(
clippy::cast_possible_truncation,
reason = "len_utf16() is always 1 or 2, so a short test line can never overflow u32"
)]
fn utf16_len(line: &str) -> u32 {
line.chars().map(char::len_utf16).sum::<usize>() as u32
}
#[test]
fn test_raw_prefix_fallback_when_character_exceeds_line() {
let line = "café";
let character = utf16_len(line) + 10;
assert_eq!(raw_prefix(line, character), "café");
}
#[test]
fn test_raw_prefix_does_not_panic_on_multibyte_char_boundary() {
let line = " \"é";
assert_eq!(raw_prefix(line, utf16_len(line)), "\"é");
}
#[test]
fn test_raw_prefix_multibyte_word_not_truncated() {
let line = "café";
assert_eq!(raw_prefix(line, utf16_len(line)), "café");
}
#[test]
fn test_raw_prefix_cjk_word_not_truncated() {
let line = "日本";
assert_eq!(raw_prefix(line, utf16_len(line)), "日本");
}
#[test]
fn test_line_at_returns_requested_line() {
let content = "one\ntwo\nthree\n";
assert_eq!(line_at(content, Position::new(1, 0)), Some("two"));
}
#[test]
fn test_line_at_out_of_range_is_none() {
let content = "one\ntwo\n";
assert_eq!(line_at(content, Position::new(5, 0)), None);
}
#[test]
fn test_is_in_toml_dependencies_basic() {
let content = r#"
[package]
name = "test"
[dependencies]
serde
"#;
assert!(is_in_toml_dependencies(content, 5));
assert!(!is_in_toml_dependencies(content, 1));
}
#[test]
fn test_is_in_toml_dependencies_dev_deps() {
let content = r"
[dev-dependencies]
tokio
";
assert!(is_in_toml_dependencies(content, 2));
}
#[test]
fn test_is_in_toml_dependencies_build_deps() {
let content = r"
[build-dependencies]
cc
";
assert!(is_in_toml_dependencies(content, 2));
}
#[test]
fn test_is_in_toml_dependencies_project_deps() {
let content = r"
[project.dependencies]
requests
";
assert!(is_in_toml_dependencies(content, 2));
}
#[test]
fn test_is_in_toml_dependencies_workspace_deps() {
let content = r#"
[workspace.dependencies]
serde = "1.0"
"#;
assert!(is_in_toml_dependencies(content, 2));
}
#[test]
fn test_is_in_toml_dependencies_target_specific() {
let content = r"
[target.'cfg(windows)'.dependencies]
winapi
";
assert!(is_in_toml_dependencies(content, 2));
}
#[test]
fn test_is_in_toml_dependencies_wrong_section() {
let content = r#"
[package]
name = "test"
[profile.release]
opt-level = 3
"#;
assert!(!is_in_toml_dependencies(content, 2));
assert!(!is_in_toml_dependencies(content, 5));
}
#[test]
fn test_is_in_toml_dependencies_multiple_sections() {
let content = r#"
[dependencies]
serde = "1.0"
[dev-dependencies]
tokio
"#;
assert!(is_in_toml_dependencies(content, 2));
assert!(is_in_toml_dependencies(content, 5));
}
const NPM_KEYS: &[&str] = &[
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
];
#[test]
fn test_is_in_json_dependencies_basic() {
let content = r#"{
"name": "test",
"dependencies": {
"express"
}
}"#;
assert!(is_in_json_dependencies(content, 3, NPM_KEYS));
assert!(!is_in_json_dependencies(content, 1, NPM_KEYS));
}
#[test]
fn test_is_in_json_dependencies_dev_deps() {
let content = r#"{
"devDependencies": {
"jest": "^29.0.0"
}
}"#;
assert!(is_in_json_dependencies(content, 2, NPM_KEYS));
}
#[test]
fn test_is_in_json_dependencies_peer_deps() {
let content = r#"{
"peerDependencies": {
"react"
}
}"#;
assert!(is_in_json_dependencies(content, 2, NPM_KEYS));
}
#[test]
fn test_is_in_json_dependencies_optional_deps() {
let content = r#"{
"optionalDependencies": {
"fsevents": "^2.0.0"
}
}"#;
assert!(is_in_json_dependencies(content, 2, NPM_KEYS));
}
#[test]
fn test_is_in_json_dependencies_outside_section() {
let content = r#"{
"name": "test",
"dependencies": {
"express": "^4.0.0"
},
"scripts": {
"start": "node index.js"
}
}"#;
assert!(is_in_json_dependencies(content, 3, NPM_KEYS));
assert!(!is_in_json_dependencies(content, 6, NPM_KEYS));
}
#[test]
fn test_is_in_json_dependencies_nested_braces() {
let content = r#"{
"dependencies": {
"package": "1.0.0"
}
}"#;
assert!(is_in_json_dependencies(content, 2, NPM_KEYS));
}
#[test]
fn test_is_in_json_dependencies_custom_keys() {
let content = r#"{
"require": {
"monolog/monolog": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^9.0"
}
}"#;
assert!(is_in_json_dependencies(
content,
2,
&["require", "require-dev"]
));
assert!(is_in_json_dependencies(
content,
5,
&["require", "require-dev"]
));
}
#[test]
fn test_is_in_xml_tag_section_basic() {
let content = r"
<project>
<dependencies>
<dependency></dependency>
</dependencies>
</project>
";
assert!(is_in_xml_tag_section(content, 3, "dependencies"));
assert!(!is_in_xml_tag_section(content, 1, "dependencies"));
}
#[test]
fn test_is_in_xml_tag_section_single_line() {
let content = "<project><dependencies></dependencies></project>\n";
assert!(is_in_xml_tag_section(content, 0, "dependencies"));
}
#[test]
fn test_is_in_xml_tag_section_attributed_tag() {
let content = r#"
<project>
<dependencies xmlns="http://maven.apache.org/POM/4.0.0">
<dependency></dependency>
</dependencies>
</project>
"#;
assert!(is_in_xml_tag_section(content, 3, "dependencies"));
}
#[test]
fn test_is_in_xml_tag_section_no_false_positive_on_longer_tag_name() {
let content = r"
<project>
<dependencyManagement>
<dependencies>
<dependency></dependency>
</dependencies>
</dependencyManagement>
</project>
";
assert!(!is_in_xml_tag_section(content, 2, "dependencies"));
assert!(is_in_xml_tag_section(content, 4, "dependencies"));
}
#[test]
fn test_strip_leading_xml_tag_for_maven() {
assert_eq!(
strip_leading_xml_tag("<artifactId>gua"),
("gua", Some("artifactId"))
);
}
#[test]
fn test_strip_leading_xml_tag_unclosed_tag_is_unchanged() {
assert_eq!(strip_leading_xml_tag("<artifactId"), ("<artifactId", None));
}
#[test]
fn test_strip_leading_xml_tag_strips_last_tag_not_first() {
let prefix = "<dependency><groupId>com.google.guava</groupId><artifactId>gua";
assert_eq!(strip_leading_xml_tag(prefix), ("gua", Some("artifactId")));
}
#[test]
fn test_strip_leading_xml_tag_after_closed_tag_is_empty() {
assert_eq!(
strip_leading_xml_tag("<artifactId>guava</artifactId>"),
("", None)
);
}
#[test]
fn test_strip_leading_xml_tag_loose_text_after_closed_tag_has_no_open_tag() {
assert_eq!(
strip_leading_xml_tag("<artifactId>guava</artifactId> comm"),
(" comm", None)
);
}
#[test]
fn test_strip_leading_xml_tag_reports_other_tag_name() {
assert_eq!(
strip_leading_xml_tag("<groupId>org.apa"),
("org.apa", Some("groupId"))
);
}
const NUGET_ELEMENTS: &[&str] = &["PackageReference", "PackageVersion", "package"];
const NUGET_ATTRS: &[&str] = &["Include", "id"];
#[test]
fn test_strip_open_xml_attribute_value_include() {
assert_eq!(
strip_open_xml_attribute_value(
"<PackageReference Include=\"Newt",
NUGET_ELEMENTS,
NUGET_ATTRS
),
"Newt"
);
}
#[test]
fn test_strip_open_xml_attribute_value_id() {
assert_eq!(
strip_open_xml_attribute_value("<package id=\"Newt", NUGET_ELEMENTS, NUGET_ATTRS),
"Newt"
);
}
#[test]
fn test_strip_open_xml_attribute_value_spaced_equals() {
assert_eq!(
strip_open_xml_attribute_value(
"<PackageReference Include = \"Newt",
NUGET_ELEMENTS,
NUGET_ATTRS
),
"Newt"
);
}
#[test]
fn test_strip_open_xml_attribute_value_single_quote() {
assert_eq!(
strip_open_xml_attribute_value(
"<PackageReference Include='Newt",
NUGET_ELEMENTS,
NUGET_ATTRS
),
"Newt"
);
}
#[test]
fn test_strip_open_xml_attribute_value_non_target_attribute_is_empty() {
assert_eq!(
strip_open_xml_attribute_value(
"<PackageReference Include=\"Foo\" Version=\"1.0",
NUGET_ELEMENTS,
NUGET_ATTRS
),
""
);
}
#[test]
fn test_strip_open_xml_attribute_value_non_target_element_is_empty() {
assert_eq!(
strip_open_xml_attribute_value("<Compile Include=\"Mode", NUGET_ELEMENTS, NUGET_ATTRS),
""
);
}
#[test]
fn test_strip_open_xml_attribute_value_element_name_only_is_empty() {
assert_eq!(
strip_open_xml_attribute_value("<PackageReference ", NUGET_ELEMENTS, NUGET_ATTRS),
""
);
}
#[test]
fn test_strip_open_xml_attribute_value_mismatched_quote_does_not_leak() {
assert_eq!(
strip_open_xml_attribute_value(
"<PackageReference Include=\"Foo'",
NUGET_ELEMENTS,
NUGET_ATTRS
),
"Foo"
);
}
#[test]
fn test_strip_open_json_key_open_key_leading_quote() {
let prefix = "\"expr";
assert_eq!(strip_open_json_key(prefix), ("expr", true));
}
#[test]
fn test_strip_open_json_key_closed_key_is_suppressed_not_reopened() {
let prefix = "\"express\"";
assert_eq!(strip_open_json_key(prefix), ("", false));
}
#[test]
fn test_strip_open_json_key_open_value_string_is_suppressed_not_key() {
let prefix = "\"express\": \"^4";
assert_eq!(strip_open_json_key(prefix), ("", false));
}
#[test]
fn test_strip_open_json_key_open_key_after_prior_closed_entry_on_same_line() {
let prefix = "\"express\": \"4.19.2\", \"look";
assert_eq!(strip_open_json_key(prefix), ("look", true));
}
#[test]
fn test_strip_open_json_key_no_quote_survives_reports_unchanged() {
let prefix = "expr";
assert_eq!(strip_open_json_key(prefix), ("expr", false));
}
#[test]
fn test_strip_open_json_key_escaped_quote_in_closed_pair_is_not_open() {
let prefix = "\"a\\\"b\": \"1\", lodash";
assert_eq!(strip_open_json_key(prefix), ("", false));
}
#[test]
fn test_strip_open_json_key_escaped_quote_inside_still_open_key() {
let prefix = "\"a\\\"b";
assert_eq!(strip_open_json_key(prefix), ("a\\\"b", true));
}
#[test]
fn test_open_quoted_tail_open_string_returns_tail() {
assert_eq!(open_quoted_tail("\"flas"), Some("flas"));
}
#[test]
fn test_open_quoted_tail_closed_string_is_none() {
assert_eq!(open_quoted_tail("\"pytest\""), None);
}
#[test]
fn test_open_quoted_tail_no_quote_is_none() {
assert_eq!(open_quoted_tail("dependencies = ["), None);
}
#[test]
fn test_open_quoted_tail_skips_escaped_quote() {
assert_eq!(open_quoted_tail("\"a\\\"b"), Some("a\\\"b"));
}
#[test]
fn test_open_quoted_tail_even_backslash_run_quote_is_real() {
assert_eq!(open_quoted_tail("\"a\\\\\""), None);
}
#[test]
fn test_count_real_quotes_skips_escaped_quote() {
let (count, last) = count_real_quotes("\"a\\\"b\"");
assert_eq!(count, 2);
assert_eq!(last, Some(5));
}
#[test]
fn test_count_real_quotes_with_single_quote_skips_escaped_quote() {
let (count, last) = count_real_quotes_with("'a\\'b'", '\'');
assert_eq!(count, 2);
assert_eq!(last, Some(5));
}
#[test]
fn test_find_closing_quote_finds_first_real_quote() {
assert_eq!(find_closing_quote("pytest\" extra", '"'), Some(6));
}
#[test]
fn test_find_closing_quote_skips_escaped_quote() {
assert_eq!(find_closing_quote("a\\\"b\"", '"'), Some(4));
}
#[test]
fn test_find_closing_quote_no_quote_is_none() {
assert_eq!(find_closing_quote("no quote here", '"'), None);
}
#[test]
fn test_find_closing_quote_single_quote_variant() {
assert_eq!(find_closing_quote("a\\'b'", '\''), Some(4));
}
}