#[cfg(feature = "multiline")]
use super::config::{has_function_concat_marker, MultilineConfig};
#[cfg(feature = "multiline")]
#[derive(Debug, PartialEq)]
pub(super) enum ContinuationType {
None,
Backslash,
PlusOperator,
DotOperator,
Implicit,
TemplateLiteral,
}
pub(crate) fn extract_prefix(var_name: &str) -> String {
let bytes = var_name.as_bytes();
let mut prefix = String::with_capacity(var_name.len());
let mut i = 0usize;
while i < bytes.len() {
if bytes[i] == b'_' || bytes[i] == b'-' {
i += 1;
continue;
}
if bytes[i..]
.get(..4)
.is_some_and(|head| head.eq_ignore_ascii_case(b"part"))
{
i += 4;
continue;
}
let Some(ch) = var_name[i..].chars().next() else {
break;
};
prefix.push(ch.to_ascii_lowercase());
i += ch.len_utf8();
}
prefix.truncate(
prefix
.trim_end_matches(|ch: char| ch.is_ascii_digit())
.len(),
);
prefix
}
pub(crate) fn fragment_assignment_name_is_credential_like(var_name: &str) -> bool {
let Some(normalized) =
crate::engine::phase2_generic::keywords::normalize_assignment_keyword(var_name)
else {
return false;
};
if normalized_assignment_name_is_public_metadata_owner(&normalized) {
return false;
}
normalized_or_fragment_base_is_credential_like(&normalized)
}
#[derive(serde::Deserialize)]
struct AssignmentNameClasses {
ambiguous_fragment: Vec<String>,
public_metadata_exact: Vec<String>,
public_metadata_suffix: Vec<String>,
}
static ASSIGNMENT_NAME_CLASSES: std::sync::LazyLock<AssignmentNameClasses> =
std::sync::LazyLock::new(|| {
let raw = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/rules/multiline-assignment-name-classes.toml"
));
match toml::from_str::<AssignmentNameClasses>(raw) {
Ok(parsed)
if !parsed.ambiguous_fragment.is_empty()
&& !parsed.public_metadata_exact.is_empty()
&& !parsed.public_metadata_suffix.is_empty() =>
{
parsed
}
Ok(_) => panic!(
"rules/multiline-assignment-name-classes.toml has an empty list; \
ambiguous_fragment, public_metadata_exact, and public_metadata_suffix \
must all be non-empty."
),
Err(error) => panic!(
"rules/multiline-assignment-name-classes.toml is invalid: {error}. \
Fix the bundled Tier-B multiline assignment-name class lists."
),
}
});
fn normalized_assignment_name_is_public_metadata_owner(normalized: &str) -> bool {
let classes = &*ASSIGNMENT_NAME_CLASSES;
classes
.public_metadata_exact
.iter()
.any(|name| name.as_str() == normalized)
|| classes
.public_metadata_suffix
.iter()
.any(|suffix| normalized.ends_with(suffix.as_str()))
}
fn normalized_or_fragment_base_is_credential_like(normalized: &str) -> bool {
if normalized_assignment_name_is_credential_like(normalized, false) {
return true;
}
if let Some(base) = strip_separated_fragment_suffix(normalized) {
return normalized_assignment_name_is_credential_like(base, true);
}
let compact: String = normalized
.bytes()
.filter(|&b| b != b'_')
.map(char::from)
.collect();
strip_compact_fragment_suffix(&compact)
.is_some_and(|base| normalized_assignment_name_is_credential_like(base, true))
}
fn normalized_assignment_name_is_credential_like(
normalized: &str,
from_fragment_suffix: bool,
) -> bool {
if !from_fragment_suffix && is_bare_ambiguous_fragment_owner(normalized) {
return false;
}
crate::entropy::keywords::normalized_assignment_keyword_is_credential(normalized)
|| crate::engine::phase2_generic::keywords::normalized_assignment_keyword_has_secret_suffix(
normalized,
)
}
fn is_bare_ambiguous_fragment_owner(normalized: &str) -> bool {
ASSIGNMENT_NAME_CLASSES
.ambiguous_fragment
.iter()
.any(|name| name.as_str() == normalized)
}
#[derive(serde::Deserialize)]
struct FragmentSuffixesFile {
suffixes: Vec<String>,
}
fn parse_fragment_suffixes(raw: &str) -> Result<Vec<String>, String> {
toml::from_str::<FragmentSuffixesFile>(raw)
.map(|parsed| parsed.suffixes)
.map_err(|error| error.to_string())
}
static FRAGMENT_SUFFIXES: std::sync::LazyLock<Vec<String>> = std::sync::LazyLock::new(|| {
match parse_fragment_suffixes(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/rules/fragment-suffixes.toml"
))) {
Ok(suffixes) => suffixes,
Err(error) => panic!(
"rules/fragment-suffixes.toml is invalid: {error}. \
Fix the bundled Tier-B suffix list."
),
}
});
fn strip_separated_fragment_suffix(normalized: &str) -> Option<&str> {
let (base, suffix) = normalized.rsplit_once('_')?;
if base.is_empty() {
return None;
}
let suffix_is_fragment = FRAGMENT_SUFFIXES.iter().any(|s| s == suffix)
|| suffix
.strip_prefix("part")
.is_some_and(|digits| !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()));
suffix_is_fragment.then_some(base)
}
fn strip_compact_fragment_suffix(compact: &str) -> Option<&str> {
for suffix in &*FRAGMENT_SUFFIXES {
if let Some(base) = compact.strip_suffix(suffix.as_str()) {
if !base.is_empty() {
return Some(base);
}
}
}
let without_digits = compact.trim_end_matches(|ch: char| ch.is_ascii_digit());
let base = without_digits.strip_suffix("part")?;
(!base.is_empty()).then_some(base)
}
#[cfg(feature = "multiline")]
pub(super) fn extract_string_part(
line: &str,
config: &MultilineConfig,
is_continuation: bool,
) -> (String, bool, ContinuationType) {
let trimmed = line.trim();
if config.backslash_continuation && trimmed.ends_with('\\') && !trimmed.ends_with("\\\\") {
let without_backslash = line
.trim_end()
.strip_suffix('\\')
.unwrap_or(line) .trim_end();
if config.plus_concatenation && without_backslash.trim().contains('+') {
if let Some((part, _)) = extract_plus_concatenation(without_backslash) {
return (part, true, ContinuationType::Backslash);
}
}
let part = extract_string_content(without_backslash);
return (part, true, ContinuationType::Backslash);
}
if let Some((part, continues)) = extract_function_concatenation(line) {
return (part, continues, ContinuationType::Implicit);
}
if config.plus_concatenation {
if let Some((part, continues)) = extract_plus_concatenation(line) {
return (part, continues, ContinuationType::PlusOperator);
}
}
if config.dot_concatenation {
if let Some((part, continues)) = extract_dot_concatenation(line) {
return (part, continues, ContinuationType::DotOperator);
}
}
if config.python_implicit {
if let Some((part, continues)) = extract_python_implicit_concatenation(line) {
return (part, continues, ContinuationType::Implicit);
}
}
if config.template_literals {
if let Some((part, continues)) = extract_template_literal_continuation(line) {
return (part, continues, ContinuationType::TemplateLiteral);
}
}
if is_continuation {
(extract_string_content(line), false, ContinuationType::None)
} else {
(line.to_string(), false, ContinuationType::None)
}
}
#[cfg(feature = "multiline")]
fn extract_string_content(line: &str) -> String {
let trimmed = line.trim().trim_end_matches([';', ',', ' ']);
for (open, close) in [('"', '"'), ('\'', '\''), ('`', '`')] {
if let Some(content) = extract_quoted_content(trimmed, open, close) {
return content;
}
}
filter_line_content(trimmed)
}
#[cfg(feature = "multiline")]
pub(crate) fn extract_quoted_content(s: &str, open: char, close: char) -> Option<String> {
let mut chars = s.chars().peekable();
let mut prev: Option<char> = None;
while let Some(&ch) = chars.peek() {
if ch == open {
break;
}
prev = Some(ch);
chars.next();
}
let is_fstring = open != '`' && matches!(prev, Some('f') | Some('F'));
if chars.next() != Some(open) {
return None;
}
let mut content = String::new();
let mut escaped = false;
while let Some(ch) = chars.next() {
if escaped {
content.push(ch);
escaped = false;
} else if ch == '\\' {
escaped = true;
content.push(ch);
} else if ch == close {
return Some(content);
} else if is_fstring && ch == '{' && chars.peek() == Some(&'{') {
chars.next();
content.push('{');
} else if is_fstring && ch == '}' && chars.peek() == Some(&'}') {
chars.next();
content.push('}');
} else if is_fstring && ch == '{' {
let mut brace_depth = 1;
let mut in_str: Option<char> = None;
for c in chars.by_ref() {
if let Some(quote) = in_str {
if c == quote {
in_str = None;
}
} else if c == '\'' || c == '"' {
in_str = Some(c);
} else if c == '{' {
brace_depth += 1;
} else if c == '}' {
brace_depth -= 1;
if brace_depth == 0 {
break;
}
}
}
} else {
content.push(ch);
}
}
None
}
#[cfg(feature = "multiline")]
#[derive(serde::Deserialize)]
struct VarDeclKeywords {
keywords: Vec<String>,
}
#[cfg(feature = "multiline")]
fn parse_var_decl_keywords(raw: &str) -> Result<Vec<String>, String> {
toml::from_str::<VarDeclKeywords>(raw)
.map(|parsed| {
parsed
.keywords
.into_iter()
.map(|kw| format!("{kw} "))
.collect()
})
.map_err(|error| error.to_string())
}
#[cfg(feature = "multiline")]
static VAR_DECL_KEYWORD_PREFIXES: std::sync::LazyLock<Vec<String>> =
std::sync::LazyLock::new(|| {
match parse_var_decl_keywords(include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/rules/multiline-var-decl-keywords.toml"
))) {
Ok(prefixes) => prefixes,
Err(error) => panic!(
"rules/multiline-var-decl-keywords.toml is invalid: {error}. \
Fix the bundled Tier-B metadata file list."
),
}
});
#[cfg(feature = "multiline")]
pub(crate) fn filter_line_content(line: &str) -> String {
let mut line = line;
for prefix in &*VAR_DECL_KEYWORD_PREFIXES {
line = line.trim_start_matches(prefix.as_str());
}
if let Some(pos) = line.find(" = ") {
return line[pos + 3..].trim().to_string();
}
if let Some(pos) = line.find("= ") {
return line[pos + 2..].trim().to_string();
}
if let Some(pos) = line.find('=') {
return line[pos + 1..].trim().to_string();
}
line.to_string()
}
#[cfg(feature = "multiline")]
fn split_concatenation_operators(expr: &str, op: u8) -> impl Iterator<Item = &str> {
let bytes = expr.as_bytes();
let mut start = 0usize;
let mut i = 0usize;
let mut quote: Option<u8> = None;
let mut escaped = false;
let mut finished = false;
std::iter::from_fn(move || {
if finished {
return None;
}
while i < bytes.len() {
let b = bytes[i];
i += 1;
if let Some(q) = quote {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == q {
quote = None;
}
} else if matches!(b, b'"' | b'\'' | b'`') {
quote = Some(b);
} else if b == op {
let segment = &expr[start..i - 1];
start = i;
return Some(segment);
}
}
finished = true;
Some(&expr[start..])
})
}
#[cfg(feature = "multiline")]
fn find_unquoted_assignment_eq(s: &str) -> Option<usize> {
let bytes = s.as_bytes();
let mut quote: Option<u8> = None;
let mut escaped = false;
for (i, &b) in bytes.iter().enumerate() {
if let Some(q) = quote {
if escaped {
escaped = false;
} else if b == b'\\' {
escaped = true;
} else if b == q {
quote = None;
}
} else if matches!(b, b'"' | b'\'' | b'`') {
quote = Some(b);
} else if b == b'=' {
return Some(i);
}
}
None
}
#[cfg(feature = "multiline")]
fn strip_assignment_prefix(trimmed: &str) -> &str {
match find_unquoted_assignment_eq(trimmed) {
Some(pos) => &trimmed[pos + 1..],
None => trimmed,
}
}
#[cfg(feature = "multiline")]
pub(crate) fn extract_plus_concatenation(line: &str) -> Option<(String, bool)> {
let trimmed = line.trim();
let ends_with_plus = trimmed.ends_with('+');
if !trimmed.contains('+') {
return None;
}
let content_to_split = strip_assignment_prefix(trimmed);
if !content_to_split.contains('"')
&& !content_to_split.contains('\'')
&& !content_to_split.contains('`')
{
return None;
}
if !ends_with_plus && !content_to_split.contains('+') {
return None;
}
let mut result = String::new();
let mut part_count = 0usize;
for part in split_concatenation_operators(content_to_split, b'+') {
part_count += 1;
let content = extract_string_content(part.trim());
if !content.is_empty() {
result.push_str(&content);
}
}
if result.is_empty() || (part_count < 2 && !ends_with_plus) {
None
} else {
Some((result, ends_with_plus))
}
}
#[cfg(feature = "multiline")]
pub(crate) fn extract_dot_concatenation(line: &str) -> Option<(String, bool)> {
let trimmed = line.trim();
if !trimmed.contains('.') {
return None;
}
let content_to_split = strip_assignment_prefix(trimmed);
if !content_to_split.contains('"')
&& !content_to_split.contains('\'')
&& !content_to_split.contains('`')
{
return None;
}
let ends_with_dot = content_to_split.trim_end().ends_with('.');
let mut result = String::new();
let mut contributing = 0usize;
for part in split_concatenation_operators(content_to_split, b'.') {
let part = part.trim();
if !part.starts_with(['"', '\'', '`']) {
continue;
}
if let Some(content) = first_quoted_literal(part) {
if !content.is_empty() {
result.push_str(&content);
contributing += 1;
}
}
}
if result.is_empty() || (contributing < 2 && !ends_with_dot) {
None
} else {
Some((result, ends_with_dot))
}
}
#[cfg(feature = "multiline")]
fn first_quoted_literal(s: &str) -> Option<String> {
for (open, close) in [('"', '"'), ('\'', '\''), ('`', '`')] {
if let Some(content) = extract_quoted_content(s, open, close) {
return Some(content);
}
}
None
}
#[cfg(feature = "multiline")]
fn scan_quoted_literal(bytes: &[u8], open: usize) -> Option<usize> {
let quote = bytes[open];
let mut j = open + 1;
let mut escaped = false;
while j < bytes.len() {
let c = bytes[j];
if escaped {
escaped = false;
} else if c == b'\\' {
escaped = true;
} else if c == quote {
return Some(j);
}
j += 1;
}
None
}
#[cfg(feature = "multiline")]
fn extract_python_implicit_concatenation(line: &str) -> Option<(String, bool)> {
let bytes = line.as_bytes();
let mut parts: Vec<&str> = Vec::new();
let mut index = 0;
let mut last_close: Option<usize> = None;
while index < bytes.len() {
let byte = bytes[index];
if byte == b'"' || byte == b'\'' {
let content_start = index + 1;
if let Some(close) = scan_quoted_literal(bytes, index) {
if let Some(prev_close) = last_close {
if line[prev_close + 1..index]
.chars()
.any(|c| !c.is_whitespace())
{
return None;
}
}
parts.push(&line[content_start..close]);
last_close = Some(close);
index = close;
} else {
break;
}
}
index += 1;
}
if parts.len() < 2 {
return None;
}
Some((parts.concat(), false))
}
#[cfg(feature = "multiline")]
fn extract_function_concatenation(line: &str) -> Option<(String, bool)> {
let trimmed = line.trim();
if !has_function_concat_marker(trimmed) {
return None;
}
let parts = extract_quoted_strings(trimmed);
if parts.len() < 2 {
return None;
}
Some((parts.join(""), false))
}
#[cfg(feature = "multiline")]
fn extract_quoted_strings(line: &str) -> Vec<String> {
let bytes = line.as_bytes();
let mut parts = Vec::new();
let mut index = 0;
while index < bytes.len() {
let byte = bytes[index];
if byte == b'"' || byte == b'\'' {
let content_start = index + 1;
if let Some(close) = scan_quoted_literal(bytes, index) {
parts.push(line[content_start..close].to_string());
index = close;
} else {
break;
}
}
index += 1;
}
parts
}
#[cfg(feature = "multiline")]
fn extract_template_literal_continuation(line: &str) -> Option<(String, bool)> {
let trimmed = line.trim();
if !trimmed.contains('`') {
return None;
}
let continues = trimmed.chars().filter(|&ch| ch == '`').count() % 2 == 1;
let mut result = String::new();
let mut in_template = false;
let mut chars = trimmed.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '`' {
in_template = !in_template;
continue;
}
if in_template && ch == '$' && chars.peek() == Some(&'{') {
chars.next();
let mut brace_depth = 1;
let mut in_str: Option<char> = None;
for c in chars.by_ref() {
if let Some(q) = in_str {
if c == q {
in_str = None;
} else {
result.push(c);
}
continue;
}
match c {
'"' | '\'' | '`' => in_str = Some(c),
'{' => brace_depth += 1,
'}' => {
brace_depth -= 1;
if brace_depth == 0 {
break;
}
}
_ => {}
}
}
continue;
}
if in_template {
result.push(ch);
}
}
Some((result, continues))
}