use alloc::{vec, vec::Vec};
use core::ops::Range;
use crate::{Dialect, DoubleEscapes, Entry, Quote, QuotedTail, Syntax};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ReferenceKind {
Variable,
Command,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ReferenceOperator {
DefaultIfUnset,
DefaultIfUnsetOrEmpty,
ErrorIfUnset,
ErrorIfUnsetOrEmpty,
AlternativeIfSet,
AlternativeIfSetAndNonEmpty,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum OperatorSet {
None,
Braced,
Basic,
Compose,
DotenvExpand,
}
impl OperatorSet {
fn accepts_variable(self, braced: bool) -> bool {
match self {
Self::None => false,
Self::Braced => braced,
Self::Basic | Self::Compose | Self::DotenvExpand => true,
}
}
fn accepts_operator(self, _operator: ReferenceOperator) -> bool {
matches!(self, Self::Compose | Self::DotenvExpand)
}
fn accepts_nested(self) -> bool {
matches!(self, Self::Compose | Self::DotenvExpand)
}
}
#[allow(clippy::struct_excessive_bools)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct ReferenceRules {
pub operators: OperatorSet,
pub single_quotes: bool,
pub escaped_dollar: bool,
pub commands: bool,
pub backtick_quotes: bool,
}
impl ReferenceRules {
#[must_use]
pub const fn with_operators(mut self, value: OperatorSet) -> Self {
self.operators = value;
self
}
#[must_use]
pub const fn with_single_quotes(mut self, value: bool) -> Self {
self.single_quotes = value;
self
}
#[must_use]
pub const fn with_escaped_dollar(mut self, value: bool) -> Self {
self.escaped_dollar = value;
self
}
#[must_use]
pub const fn with_commands(mut self, value: bool) -> Self {
self.commands = value;
self
}
#[must_use]
pub const fn with_backtick_quotes(mut self, value: bool) -> Self {
self.backtick_quotes = value;
self
}
}
impl ReferenceRules {
pub const NONE: Self = Self {
operators: OperatorSet::None,
single_quotes: false,
escaped_dollar: false,
commands: false,
backtick_quotes: false,
};
pub const PORTABLE: Self = Self {
operators: OperatorSet::Basic,
single_quotes: false,
escaped_dollar: false,
commands: false,
backtick_quotes: false,
};
pub const COMPOSE: Self = Self {
operators: OperatorSet::Compose,
single_quotes: false,
escaped_dollar: false,
commands: false,
backtick_quotes: false,
};
pub const DOTENV_EXPAND: Self = Self {
operators: OperatorSet::DotenvExpand,
single_quotes: true,
escaped_dollar: false,
commands: true,
backtick_quotes: false,
};
}
impl Default for ReferenceRules {
fn default() -> Self {
Self::PORTABLE
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct ReferenceContainer {
pub kind: ReferenceKind,
pub operator: Option<ReferenceOperator>,
pub escaped: bool,
pub closed: bool,
pub name_nonempty: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct Reference {
pub kind: ReferenceKind,
pub span: Range<usize>,
pub quote: Quote,
pub escaped: bool,
pub depth: usize,
pub braced: bool,
pub closed: bool,
pub containers: Vec<ReferenceContainer>,
pub name: Option<Range<usize>>,
pub operator: Option<ReferenceOperator>,
pub operator_span: Option<Range<usize>>,
pub argument: Option<Range<usize>>,
pub command: Option<Range<usize>>,
}
impl Reference {
#[must_use]
pub fn is_eligible(&self, rules: ReferenceRules) -> bool {
if !self.closed {
return false;
}
if self.escaped && !rules.escaped_dollar {
return false;
}
if self.quote == Quote::Single && !rules.single_quotes {
return false;
}
if self.quote == Quote::Backtick && !rules.backtick_quotes {
return false;
}
if self.depth > 0 && !rules.operators.accepts_nested() {
return false;
}
for container in &self.containers {
if container.escaped && !rules.escaped_dollar {
return false;
}
if !container.closed {
return false;
}
if container.kind == ReferenceKind::Command && !rules.commands {
return false;
}
if container.kind == ReferenceKind::Variable {
if !container.name_nonempty {
return false;
}
if container
.operator
.is_some_and(|operator| !rules.operators.accepts_operator(operator))
{
return false;
}
}
}
match self.kind {
ReferenceKind::Command => rules.commands,
ReferenceKind::Variable => {
if self.name.as_ref().map_or(true, Range::is_empty) {
return false;
}
if !rules.operators.accepts_variable(self.braced) {
return false;
}
self.operator
.map_or(true, |operator| rules.operators.accepts_operator(operator))
}
}
}
}
impl Entry<'_> {
#[must_use]
pub fn references(self) -> Vec<Reference> {
let tokens = self.tokens();
let Some(body) = tokens.body.clone() else {
return Vec::new();
};
let dialect = self.document.dialect;
let source = self.document.source();
let mut pending = vec![(body, self.quote(), 0, Vec::new())];
if dialect.quoted_tail == QuotedTail::Append {
if let Some(tail) = tokens.quoted_tail.clone() {
pending.push((tail, Quote::None, 0, Vec::new()));
}
}
let mut scanner = Scanner {
source,
dialect,
references: Vec::new(),
pending,
};
while let Some((range, quote, depth, containers)) = scanner.pending.pop() {
scanner.scan(&range, quote, depth, &containers);
}
let mut references = scanner.references;
references
.sort_by_key(|reference| (reference.span.start, reference.span.end, reference.depth));
references
}
}
struct Scanner<'a> {
source: &'a str,
dialect: Dialect,
references: Vec<Reference>,
pending: Vec<(Range<usize>, Quote, usize, Vec<ReferenceContainer>)>,
}
impl Scanner<'_> {
fn scan(
&mut self,
range: &Range<usize>,
quote: Quote,
depth: usize,
containers: &[ReferenceContainer],
) {
let source = self.source;
let dialect = self.dialect;
let bytes = source.as_bytes();
let mut at = range.start;
while at < range.end {
if bytes[at] != b'$' {
at += 1;
continue;
}
let escaped = escaped_at(source, range, at, quote, dialect, depth > 0);
if at + 1 < range.end && bytes[at + 1] == b'{' {
let (reference, nested) =
braced(source, range, at, quote, escaped, depth, containers);
let end = reference.span.end;
let container = container_for(&reference);
self.references.push(reference);
if let Some(nested) = nested {
self.pending.push((
nested,
quote,
depth.saturating_add(1),
nested_containers(containers, container),
));
}
at = end;
continue;
}
if at + 1 < range.end && bytes[at + 1] == b'(' {
let (reference, nested) =
command(source, range, at, quote, escaped, depth, containers);
let end = reference.span.end;
let container = container_for(&reference);
self.references.push(reference);
if let Some(nested) = nested {
self.pending.push((
nested,
quote,
depth.saturating_add(1),
nested_containers(containers, container),
));
}
at = end;
continue;
}
if at + 1 < range.end && (bytes[at + 1].is_ascii_alphabetic() || bytes[at + 1] == b'_')
{
let name_start = at + 1;
let mut name_end = name_start + 1;
while name_end < range.end
&& (bytes[name_end].is_ascii_alphanumeric() || bytes[name_end] == b'_')
{
name_end += 1;
}
self.references.push(Reference {
kind: ReferenceKind::Variable,
span: at..name_end,
quote,
escaped,
depth,
braced: false,
closed: true,
containers: containers.to_vec(),
name: Some(name_start..name_end),
operator: None,
operator_span: None,
argument: None,
command: None,
});
at = name_end;
continue;
}
at += 1;
}
}
}
fn container_for(reference: &Reference) -> ReferenceContainer {
ReferenceContainer {
kind: reference.kind,
operator: reference.operator,
escaped: reference.escaped,
closed: reference.closed,
name_nonempty: match reference.kind {
ReferenceKind::Command => true,
ReferenceKind::Variable => reference.name.as_ref().is_some_and(|name| !name.is_empty()),
},
}
}
fn nested_containers(
parent: &[ReferenceContainer],
container: ReferenceContainer,
) -> Vec<ReferenceContainer> {
let mut nested = parent.to_vec();
nested.push(container);
nested
}
fn braced(
source: &str,
range: &Range<usize>,
at: usize,
quote: Quote,
escaped: bool,
depth: usize,
containers: &[ReferenceContainer],
) -> (Reference, Option<Range<usize>>) {
let body_start = at + 2;
let (close, body_end) = matching_delimited(source, body_start, range.end, Delimiter::Brace);
let end = close.map_or(range.end, |close| close + 1);
let (name_end, operator, operator_span) = name_operator(source, body_start, body_end);
let argument = operator_span.as_ref().map(|span| span.end..body_end);
(
Reference {
kind: ReferenceKind::Variable,
span: at..end,
quote,
escaped,
depth,
braced: true,
closed: close.is_some(),
containers: containers.to_vec(),
name: Some(body_start..name_end),
operator,
operator_span,
argument: argument.clone(),
command: None,
},
argument.filter(|argument| !argument.is_empty()),
)
}
fn command(
source: &str,
range: &Range<usize>,
at: usize,
quote: Quote,
escaped: bool,
depth: usize,
containers: &[ReferenceContainer],
) -> (Reference, Option<Range<usize>>) {
let payload_start = at + 2;
let (close, payload_end) =
matching_delimited(source, payload_start, range.end, Delimiter::Paren);
let end = close.map_or(range.end, |close| close + 1);
let payload = payload_start..payload_end;
(
Reference {
kind: ReferenceKind::Command,
span: at..end,
quote,
escaped,
depth,
braced: false,
closed: close.is_some(),
containers: containers.to_vec(),
name: None,
operator: None,
operator_span: None,
argument: None,
command: Some(payload.clone()),
},
(!payload.is_empty()).then_some(payload),
)
}
fn name_operator(
source: &str,
start: usize,
end: usize,
) -> (usize, Option<ReferenceOperator>, Option<Range<usize>>) {
let bytes = source.as_bytes();
let mut at = start;
while at < end {
let found = match (bytes[at], bytes.get(at + 1).copied()) {
(b':', Some(b'-')) => Some((ReferenceOperator::DefaultIfUnsetOrEmpty, 2)),
(b'-', _) => Some((ReferenceOperator::DefaultIfUnset, 1)),
(b':', Some(b'?')) => Some((ReferenceOperator::ErrorIfUnsetOrEmpty, 2)),
(b'?', _) => Some((ReferenceOperator::ErrorIfUnset, 1)),
(b':', Some(b'+')) => Some((ReferenceOperator::AlternativeIfSetAndNonEmpty, 2)),
(b'+', _) => Some((ReferenceOperator::AlternativeIfSet, 1)),
_ => None,
};
if let Some((operator, length)) = found {
return (at, Some(operator), Some(at..at + length));
}
at += 1;
}
(end, None, None)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Delimiter {
Brace,
Paren,
}
fn literal_balanced(
source: &str,
start: usize,
end: usize,
open: u8,
close: u8,
) -> (Option<usize>, usize) {
let bytes = source.as_bytes();
let mut at = start;
let mut depth = 1;
let mut quote = None;
while at < end {
match quote {
Some(b'\'') => {
if bytes[at] == b'\'' {
quote = None;
}
at += 1;
}
Some(_) => {
if bytes[at] == b'\\' {
at = at.saturating_add(2);
} else {
if bytes[at] == b'"' {
quote = None;
}
at += 1;
}
}
None => match bytes[at] {
b'\\' => at = at.saturating_add(2),
b'\'' => {
quote = Some(b'\'');
at += 1;
}
b'"' => {
quote = Some(b'"');
at += 1;
}
byte if byte == open => {
depth += 1;
at += 1;
}
byte if byte == close => {
depth -= 1;
if depth == 0 {
return (Some(at), at);
}
at += 1;
}
_ => at += 1,
},
}
}
(None, end)
}
fn matching_delimited(
source: &str,
start: usize,
end: usize,
initial: Delimiter,
) -> (Option<usize>, usize) {
let bytes = source.as_bytes();
let mut stack = vec![initial];
let mut at = start;
let mut quote = None;
while at < end {
match quote {
Some(b'\'') => {
if bytes[at] == b'\'' {
quote = None;
}
at += 1;
}
Some(_) => {
if bytes[at] == b'\\' {
at = at.saturating_add(2);
} else {
if bytes[at] == b'"' {
quote = None;
}
at += 1;
}
}
None => match bytes[at] {
b'\\' if bytes.get(at + 1) == Some(&b'$') => {
at = at.saturating_add(2);
match bytes.get(at).copied() {
Some(b'{') => {
let (close, end) = literal_balanced(source, at + 1, end, b'{', b'}');
at = close.map_or(end, |close| close + 1);
}
Some(b'(') => {
let (close, end) = literal_balanced(source, at + 1, end, b'(', b')');
at = close.map_or(end, |close| close + 1);
}
_ => {}
}
}
b'\\' => at = at.saturating_add(2),
b'\'' => {
quote = Some(b'\'');
at += 1;
}
b'"' => {
quote = Some(b'"');
at += 1;
}
b'$' if bytes.get(at + 1) == Some(&b'{') => {
stack.push(Delimiter::Brace);
at += 2;
}
b'$' if bytes.get(at + 1) == Some(&b'(') => {
stack.push(Delimiter::Paren);
at += 2;
}
b'(' if stack.last() == Some(&Delimiter::Paren) => {
stack.push(Delimiter::Paren);
at += 1;
}
b'}' if stack.last() == Some(&Delimiter::Brace) => {
stack.pop();
if stack.is_empty() {
return (Some(at), at);
}
at += 1;
}
b')' if stack.last() == Some(&Delimiter::Paren) => {
stack.pop();
if stack.is_empty() {
return (Some(at), at);
}
at += 1;
}
_ => at += 1,
},
}
}
(None, end)
}
fn escaped_at(
source: &str,
range: &Range<usize>,
at: usize,
quote: Quote,
dialect: Dialect,
nested: bool,
) -> bool {
let bytes = source.as_bytes();
let mut backslashes = 0;
let mut cursor = at;
while cursor > range.start && bytes[cursor - 1] == b'\\' {
backslashes += 1;
cursor -= 1;
}
let backslash = backslashes % 2 == 1
&& ((quote == Quote::Double && dialect.double_escapes.contains(DoubleEscapes::DOLLAR))
|| (quote == Quote::None && nested));
let dollar = dialect.syntax == Syntax::Compose && {
let mut dollars = 0;
let mut cursor = at;
while cursor > range.start && bytes[cursor - 1] == b'$' {
dollars += 1;
cursor -= 1;
}
(dollars + 1) % 2 == 0
};
backslash || dollar
}