use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use std::str;
use std::str::FromStr;
use backhopper_erlang_scan::{
arity_of_args, count_top_level_commas, dot_terminates, hash_inside_number, is_name_byte,
is_type_declaration_keyword, parse_callable_signature, quoted_atom_span,
skip_char_literal_span, string_span, take_balanced_parens,
};
use crate::compat::target_tree_index::TargetTreeIndex;
use crate::compat::test_suite::module_resolves;
use crate::model::names::{Arity, FunctionName, ModuleName, RelativePath, TypeName};
use crate::model::symbol::RefContext;
use crate::model::verdict::IncludeDirective;
use crate::snapshot::spec_normalize::normalize_signature;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BehaviourRef {
pub behaviour: ModuleName,
pub line: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IncludeRef {
pub directive: IncludeDirective,
pub line: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ScannedUse {
pub name: String,
pub line: u32,
}
pub fn extract_defined_macros(src: &str) -> BTreeSet<String> {
iter_attribute_bodies(src, &["define"])
.into_iter()
.filter_map(|hit| parse_macro_define_name(hit.body))
.collect()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MacroDef {
pub params: Option<usize>,
pub body: String,
}
pub fn extract_defined_macro_values(src: &str) -> BTreeMap<String, Vec<MacroDef>> {
let mut out: BTreeMap<String, Vec<MacroDef>> = BTreeMap::new();
for hit in iter_attribute_bodies(src, &["define"]) {
let Some((name, params, value)) = parse_macro_define(hit.body) else {
continue;
};
out.entry(name).or_default().push(MacroDef {
params,
body: normalize_macro_body(value),
});
}
out
}
fn parse_macro_define(body: &str) -> Option<(String, Option<usize>, &str)> {
let trimmed = body.trim_start();
let name_end = trimmed
.char_indices()
.find(|(_, c)| !(c.is_ascii_alphanumeric() || *c == '_' || *c == '@'))
.map(|(i, _)| i)
.unwrap_or(trimmed.len());
if name_end == 0 {
return None;
}
let name = trimmed[..name_end].to_owned();
let rest = trimmed[name_end..].trim_start();
let (params, after) = if rest.starts_with('(') {
let (args, after_args) = take_balanced_parens(rest)?;
let count = if args.trim().is_empty() {
0
} else {
count_top_level_commas(args) + 1
};
(Some(count), after_args.trim_start())
} else {
(None, rest)
};
let value = after.strip_prefix(',')?;
Some((name, params, value))
}
fn normalize_macro_body(body: &str) -> String {
let mut out = String::with_capacity(body.len());
let mut chars = body.chars();
let mut in_string = false;
let mut in_atom = false;
while let Some(c) = chars.next() {
if in_string || in_atom {
out.push(c);
match c {
'\\' => {
if let Some(next) = chars.next() {
out.push(next);
}
}
'"' if in_string => in_string = false,
'\'' if in_atom => in_atom = false,
_ => {}
}
continue;
}
match c {
'%' => {
for next in chars.by_ref() {
if next == '\n' {
break;
}
}
if !out.ends_with(' ') {
out.push(' ');
}
}
'"' => {
in_string = true;
out.push(c);
}
'\'' => {
in_atom = true;
out.push(c);
}
'$' => {
out.push(c);
if let Some(next) = chars.next() {
out.push(next);
}
}
c if c.is_whitespace() => {
if !out.ends_with(' ') {
out.push(' ');
}
}
c => out.push(c),
}
}
out.trim().to_owned()
}
pub fn extract_macro_uses(src: &str) -> Vec<ScannedUse> {
scan_uses(src, b'?', |bytes, j| {
let mut k = j;
while k < bytes.len() && bytes[k] == b'?' {
k += 1;
}
let start = k;
while k < bytes.len() && is_name_byte(bytes[k]) {
k += 1;
}
(start, k)
})
}
pub fn extract_record_uses(src: &str) -> Vec<ScannedUse> {
scan_uses(src, b'#', |bytes, j| {
let mut start = j;
if bytes.get(start) == Some(&b'\'') {
start += 1;
let mut k = start;
while k < bytes.len() && bytes[k] != b'\'' {
k += 1;
}
return (start, k);
}
match bytes.get(start) {
Some(b) if b.is_ascii_lowercase() => {}
_ => return (start, start),
}
let mut k = start;
while k < bytes.len() && is_name_byte(bytes[k]) {
k += 1;
}
(start, k)
})
}
pub fn extract_defined_records(src: &str) -> BTreeSet<String> {
iter_attribute_bodies(src, &["record"])
.into_iter()
.filter_map(|hit| parse_single_atom_argument(hit.body))
.collect()
}
fn scan_uses(
src: &str,
sigil: u8,
name_span: impl Fn(&[u8], usize) -> (usize, usize),
) -> Vec<ScannedUse> {
let bytes = src.as_bytes();
let mut out = Vec::new();
let mut i = 0;
let mut line = 1u32;
while i < bytes.len() {
match bytes[i] {
b'\n' => {
line += 1;
i += 1;
}
b'%' => {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
}
b'"' | b'~' => {
let end = i + string_span(bytes, i).unwrap_or(1);
line += newline_count(&bytes[i..end]);
i = end;
}
b'$' => i += skip_char_literal_span(bytes, i),
b'\'' => {
let end = i + quoted_atom_span(bytes, i).unwrap_or(1);
line += newline_count(&bytes[i..end]);
i = end;
}
b'#' if sigil == b'#' && hash_inside_number(bytes, i) => i += 1,
b if b == sigil => {
let (start, end) = name_span(bytes, i + 1);
if end > start {
out.push(ScannedUse {
name: String::from_utf8_lossy(&bytes[start..end]).into_owned(),
line,
});
}
i = end.max(i + 1);
}
_ => i += 1,
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FunctionSignature {
pub name: String,
pub arity: usize,
pub line: u32,
pub is_definition: bool,
}
pub fn extract_function_signatures(src: &str) -> Vec<FunctionSignature> {
signatures_with_line_context(src, None)
}
pub fn extract_function_signatures_with_context(
src: &str,
ctx: &[RefContext],
) -> Vec<FunctionSignature> {
signatures_with_line_context(src, Some(ctx))
}
fn signatures_with_line_context(src: &str, ctx: Option<&[RefContext]>) -> Vec<FunctionSignature> {
let bytes = src.as_bytes();
let mut out = Vec::new();
let mut i = 0;
let mut line = 1u32;
let mut in_attribute = line_starts_attribute(ctx, line);
while i < bytes.len() {
match bytes[i] {
b'\n' => {
line += 1;
if ctx.is_some() {
in_attribute = line_starts_attribute(ctx, line);
}
i += 1;
}
b'%' => {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
}
b'"' | b'~' => {
let end = i + string_span(bytes, i).unwrap_or(1);
line += count_newlines(&bytes[i..end]);
i = end;
}
b'$' => i += skip_char_literal_span(bytes, i),
b'\'' => {
let end = i + quoted_atom_span(bytes, i).unwrap_or(1);
line += count_newlines(&bytes[i..end]);
i = end;
}
b'-' if !in_attribute && at_form_start(bytes, i) => {
in_attribute = true;
i += 1;
}
b'.' if in_attribute && dot_terminates(bytes, i) => {
in_attribute = false;
i += 1;
}
b if b.is_ascii_uppercase() || b == b'_' => {
while i < bytes.len() && is_name_byte(bytes[i]) {
i += 1;
}
}
b if b.is_ascii_lowercase() => {
let start = i;
let mut j = i;
while j < bytes.len() && is_name_byte(bytes[j]) {
j += 1;
}
let name = &bytes[start..j];
let qualified = start > 0 && matches!(bytes[start - 1], b':' | b'?' | b'#');
if bytes.get(j) == Some(&b'(') && !qualified && !is_reserved_word(name) {
if let Some(close) = match_parens(bytes, j + 1) {
if !in_attribute {
let arity = arity_of_args(&src[j + 1..close]);
let is_definition = followed_by_clause_arrow(bytes, close + 1)
|| starts_line(bytes, start);
out.push(FunctionSignature {
name: String::from_utf8_lossy(name).into_owned(),
arity,
line,
is_definition,
});
}
line += count_newlines(&bytes[start..=close]);
i = close + 1;
continue;
}
}
i = j;
}
_ => i += 1,
}
}
out
}
fn line_starts_attribute(ctx: Option<&[RefContext]>, line: u32) -> bool {
ctx.is_some_and(|ctx| {
ctx.get(line.saturating_sub(1) as usize)
.is_some_and(|c| c.is_attribute())
})
}
fn starts_line(bytes: &[u8], start: usize) -> bool {
start == 0 || bytes[start - 1] == b'\n'
}
fn at_form_start(bytes: &[u8], pos: usize) -> bool {
bytes[..pos]
.iter()
.rev()
.take_while(|&&b| b != b'\n')
.all(|&b| b.is_ascii_whitespace())
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct ImportedFunction {
pub module: ModuleName,
pub function: FunctionName,
pub arity: Arity,
}
pub fn extract_imports(src: &str) -> BTreeSet<ImportedFunction> {
let mut out = BTreeSet::new();
for hit in iter_attribute_bodies(src, &["import"]) {
let Some(open) = hit.body.find('[') else {
continue;
};
let Some(close) = hit.body[open..].find(']') else {
continue;
};
let module_text = hit.body[..open].trim().trim_end_matches(',').trim();
let Ok(module) = ModuleName::from_str(module_text) else {
continue;
};
for entry in hit.body[open + 1..open + close].split(',') {
if let Some((name, arity)) = parse_fun_arity(entry)
&& let (Ok(function), Ok(arity)) =
(FunctionName::from_str(&name), Arity::try_from(arity))
{
out.insert(ImportedFunction {
module: module.clone(),
function,
arity,
});
}
}
}
out
}
#[must_use]
pub fn declares_parse_transform(src: &str) -> bool {
iter_attribute_bodies(src, &["compile"])
.iter()
.any(|hit| hit.body.contains("parse_transform"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportSet {
pub exports: BTreeSet<(FunctionName, Arity)>,
pub complete: bool,
}
pub fn extract_exports(src: &str) -> ExportSet {
let mut exports = BTreeSet::new();
let mut complete = true;
for hit in iter_attribute_bodies(src, &["compile"]) {
if hit.body.contains("export_all") || hit.body.contains("parse_transform") {
complete = false;
}
}
for hit in iter_attribute_bodies(src, &["export"]) {
let Some(open) = hit.body.find('[') else {
continue;
};
let Some(close) = hit.body[open..].find(']') else {
continue;
};
for entry in hit.body[open + 1..open + close].split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if entry.contains('?') {
complete = false;
continue;
}
if let Some((name, arity)) = parse_fun_arity(entry)
&& let (Ok(function), Ok(arity)) =
(FunctionName::from_str(&name), Arity::try_from(arity))
{
exports.insert((function, arity));
}
}
}
ExportSet { exports, complete }
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportedType {
pub name: TypeName,
pub arity: Arity,
pub line: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExportedTypeSet {
pub types: Vec<ExportedType>,
pub complete: bool,
}
pub fn extract_exported_types(src: &str) -> ExportedTypeSet {
let mut types = Vec::new();
let mut complete = !declares_parse_transform(src);
for hit in iter_attribute_bodies(src, &["export_type"]) {
let Some(open) = hit.body.find('[') else {
continue;
};
let Some(close) = hit.body[open..].find(']') else {
continue;
};
for entry in hit.body[open + 1..open + close].split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
if entry.contains('?') {
complete = false;
continue;
}
if let Some((name, arity)) = parse_fun_arity(entry)
&& let (Ok(name), Ok(arity)) = (TypeName::from_str(&name), Arity::try_from(arity))
{
types.push(ExportedType {
name,
arity,
line: hit.line,
});
}
}
}
ExportedTypeSet { types, complete }
}
pub fn has_macro_expanded_attribute(src: &str) -> bool {
let bytes = src.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' => {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
}
b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
b'$' => i += skip_char_literal_span(bytes, i),
b'\'' => i += quoted_atom_span(bytes, i).unwrap_or(1),
b'-' if at_form_start(bytes, i) => {
let rest = &bytes[i + 1..];
let after_space = rest.iter().position(|b| !b.is_ascii_whitespace());
if after_space.is_some_and(|p| rest[p] == b'?') {
return true;
}
i += 1;
}
_ => i += 1,
}
}
false
}
pub fn extract_defined_types(src: &str) -> BTreeSet<(TypeName, Arity)> {
let bytes = src.as_bytes();
let mut out = BTreeSet::new();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' => {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
}
b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
b'$' => i += skip_char_literal_span(bytes, i),
b'\'' => i += quoted_atom_span(bytes, i).unwrap_or(1),
b'-' if at_form_start(bytes, i) => {
let name_start = i + 1;
let mut name_end = name_start;
while name_end < bytes.len() && is_attribute_name_char(bytes[name_end]) {
name_end += 1;
}
let name = str::from_utf8(&bytes[name_start..name_end]).unwrap_or("");
if !is_type_declaration_keyword(name) {
i += 1;
continue;
}
let body_start = name_end;
let body_end = form_end(bytes, body_start);
let body = str::from_utf8(&bytes[body_start..body_end]).unwrap_or("");
if let Some((name, arity)) = parse_type_name_arity(strip_outer_parens(body))
&& let (Ok(name), Ok(arity)) =
(TypeName::from_str(&name), Arity::try_from(arity))
{
out.insert((name, arity));
}
i = body_end;
}
_ => i += 1,
}
}
out
}
fn parse_type_name_arity(body: &str) -> Option<(String, usize)> {
let trimmed = body.trim();
let name_end = trimmed
.bytes()
.position(|b| !is_name_byte(b))
.unwrap_or(trimmed.len());
if name_end == 0 {
return None;
}
let name = &trimmed[..name_end];
let (args, rest) = take_balanced_parens(trimmed[name_end..].trim_start())?;
rest.trim_start().strip_prefix("::")?;
let arity = if args.trim().is_empty() {
0
} else {
count_top_level_commas(args) + 1
};
Some((name.to_owned(), arity))
}
pub type SpecTable = BTreeMap<(FunctionName, Arity), String>;
pub fn extract_specs(src: &str) -> SpecTable {
extract_signature_forms(src, b"spec")
}
pub fn extract_callbacks(src: &str) -> SpecTable {
extract_signature_forms(src, b"callback")
}
pub fn extract_optional_callbacks(src: &str) -> BTreeSet<(FunctionName, Arity)> {
let mut out = BTreeSet::new();
for hit in iter_attribute_bodies(src, &["optional_callbacks"]) {
let Some(open) = hit.body.find('[') else {
continue;
};
let Some(close) = hit.body[open..].find(']') else {
continue;
};
for entry in hit.body[open + 1..open + close].split(',') {
if let Some((name, arity)) = parse_fun_arity(entry)
&& let (Ok(function), Ok(arity)) =
(FunctionName::from_str(&name), Arity::try_from(arity))
{
out.insert((function, arity));
}
}
}
out
}
fn extract_signature_forms(src: &str, keyword: &[u8]) -> SpecTable {
let bytes = src.as_bytes();
let mut out = SpecTable::new();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' => {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
}
b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
b'$' => i += skip_char_literal_span(bytes, i),
b'\'' => i += quoted_atom_span(bytes, i).unwrap_or(1),
b'-' if at_form_start(bytes, i) && at_keyword(bytes, i + 1, keyword) => {
let body_start = i + 1 + keyword.len();
let body_end = form_end(bytes, body_start);
let body = str::from_utf8(&bytes[body_start..body_end]).unwrap_or("");
if let Some(sig) = parse_callable_signature(strip_outer_parens(body))
&& let Ok(function) = FunctionName::from_str(&sig.name)
{
out.insert(
(function, Arity::new(sig.arity)),
normalize_signature(&sig.signature),
);
}
i = body_end;
}
_ => i += 1,
}
}
out
}
fn at_keyword(bytes: &[u8], pos: usize, keyword: &[u8]) -> bool {
bytes[pos..].starts_with(keyword)
&& !bytes
.get(pos + keyword.len())
.copied()
.is_some_and(is_attribute_name_char)
}
fn strip_outer_parens(body: &str) -> &str {
let trimmed = body.trim();
match trimmed.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
Some(inner) => inner.trim(),
None => trimmed,
}
}
fn form_end(bytes: &[u8], start: usize) -> usize {
let mut i = start;
while i < bytes.len() {
match bytes[i] {
b'%' => {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
}
b'"' | b'~' => i += string_span(bytes, i).unwrap_or(1),
b'$' => i += skip_char_literal_span(bytes, i),
b'\'' => i += quoted_atom_span(bytes, i).unwrap_or(1),
b'.' if dot_terminates(bytes, i) => return i,
_ => i += 1,
}
}
i
}
fn parse_fun_arity(entry: &str) -> Option<(String, usize)> {
let (name, arity) = entry.trim().split_once('/')?;
let name = name.trim();
if name.is_empty() || !name.bytes().all(is_name_byte) {
return None;
}
Some((name.to_owned(), arity.trim().parse().ok()?))
}
fn is_reserved_word(name: &[u8]) -> bool {
matches!(
name,
b"after"
| b"and"
| b"andalso"
| b"band"
| b"begin"
| b"bnot"
| b"bor"
| b"bsl"
| b"bsr"
| b"bxor"
| b"case"
| b"catch"
| b"cond"
| b"div"
| b"end"
| b"fun"
| b"if"
| b"let"
| b"maybe"
| b"not"
| b"of"
| b"or"
| b"orelse"
| b"receive"
| b"rem"
| b"try"
| b"when"
| b"xor"
)
}
fn followed_by_clause_arrow(bytes: &[u8], pos: usize) -> bool {
let mut i = pos;
while i < bytes.len() && (bytes[i] as char).is_whitespace() {
i += 1;
}
bytes[i..].starts_with(b"->") || bytes[i..].starts_with(b"when")
}
fn count_newlines(bytes: &[u8]) -> u32 {
let mut n = 0u32;
for &b in bytes {
if b == b'\n' {
n += 1;
}
}
n
}
#[must_use]
pub fn is_predefined_macro(name: &str) -> bool {
matches!(
name,
"MODULE"
| "MODULE_STRING"
| "FILE"
| "LINE"
| "MACHINE"
| "FUNCTION_NAME"
| "FUNCTION_ARITY"
| "OTP_RELEASE"
| "FEATURE_AVAILABLE"
| "FEATURE_ENABLED"
)
}
fn parse_macro_define_name(body: &str) -> Option<String> {
let trimmed = body.trim_start();
let mut chars = trimmed.char_indices();
let (_, first) = chars.next()?;
if first == '\'' {
let end = trimmed[1..].find('\'')?;
return Some(trimmed[1..=end].to_owned());
}
if !(first.is_ascii_alphanumeric() || first == '_' || first == '@') {
return None;
}
let end = trimmed
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '@'))
.unwrap_or(trimmed.len());
Some(trimmed[..end].to_owned())
}
pub fn extract_behaviours(src: &str) -> Vec<BehaviourRef> {
extract_attribute_atom_argument(src, &["behaviour", "behavior"])
.into_iter()
.filter_map(|(atom, line)| {
ModuleName::new(atom)
.ok()
.map(|m| BehaviourRef { behaviour: m, line })
})
.collect()
}
pub fn extract_includes(src: &str) -> Vec<IncludeRef> {
let mut out = Vec::new();
for (name, path, line) in extract_attribute_string_argument(src, &["include", "include_lib"]) {
let directive = match name.as_str() {
"include" => IncludeDirective::Include { path },
"include_lib" => IncludeDirective::IncludeLib { path },
_ => continue,
};
out.push(IncludeRef { directive, line });
}
out
}
pub fn behaviour_resolves(
target: &TargetTreeIndex,
search_path_globs: &[String],
behaviour: &ModuleName,
) -> bool {
module_resolves(target, search_path_globs, behaviour)
}
pub fn resolve_include(
target: &TargetTreeIndex,
source_path: &RelativePath,
directive: &IncludeDirective,
) -> Result<RelativePath, Vec<RelativePath>> {
let candidates = include_candidates(source_path, directive);
for c in &candidates {
if target.contains_path(Path::new(c.as_str())) {
return Ok(c.clone());
}
}
Err(candidates)
}
fn include_candidates(
source_path: &RelativePath,
directive: &IncludeDirective,
) -> Vec<RelativePath> {
match directive {
IncludeDirective::Include { path } => {
let source_dir = Path::new(source_path.as_str())
.parent()
.map(Path::to_path_buf)
.unwrap_or_default();
let joined = source_dir.join(path);
normalised_relative_path(&joined).into_iter().collect()
}
IncludeDirective::IncludeLib { path } => include_lib_candidates(path),
}
}
fn include_lib_candidates(path: &str) -> Vec<RelativePath> {
let mut segs = path.splitn(2, '/');
let app = match segs.next() {
Some(s) if !s.is_empty() => s,
_ => return Vec::new(),
};
let rest = match segs.next() {
Some(s) => s,
None => return Vec::new(),
};
let mut out = Vec::new();
for prefix in ["deps", "apps", "lib"] {
let candidate = PathBuf::from(prefix).join(app).join(rest);
if let Some(rp) = normalised_relative_path(&candidate) {
out.push(rp);
}
}
out
}
fn normalised_relative_path(p: &Path) -> Option<RelativePath> {
let s = p.to_string_lossy().replace('\\', "/");
RelativePath::new(s).ok()
}
fn extract_attribute_atom_argument(src: &str, names: &[&str]) -> Vec<(String, u32)> {
let mut out = Vec::new();
for hit in iter_attribute_bodies(src, names) {
if let Some(atom) = parse_single_atom_argument(hit.body) {
out.push((atom, hit.line));
}
}
out
}
fn extract_attribute_string_argument(src: &str, names: &[&str]) -> Vec<(String, String, u32)> {
let mut out = Vec::new();
for hit in iter_attribute_bodies(src, names) {
if let Some(path) = parse_single_string_argument(hit.body) {
out.push((hit.name.to_owned(), path, hit.line));
}
}
out
}
#[derive(Debug, Clone, Copy)]
struct AttrHit<'a> {
name: &'a str,
body: &'a str,
line: u32,
}
fn iter_attribute_bodies<'a>(src: &'a str, names: &'a [&'a str]) -> Vec<AttrHit<'a>> {
let bytes = src.as_bytes();
let mut out = Vec::new();
let mut i = 0;
let mut line = 1u32;
while i < bytes.len() {
let b = bytes[i];
if b == b'\n' {
line += 1;
i += 1;
continue;
}
if b == b'%' {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
if b == b'"' || b == b'~' {
let end = i + string_span(bytes, i).unwrap_or(1);
line += newline_count(&bytes[i..end]);
i = end;
continue;
}
if b == b'$' {
i += skip_char_literal_span(bytes, i);
continue;
}
if b == b'\'' {
let end = i + quoted_atom_span(bytes, i).unwrap_or(1);
line += newline_count(&bytes[i..end]);
i = end;
continue;
}
if b == b'-'
&& let Some(name) = match_attribute_keyword(bytes, i + 1, names)
{
let name_end = i + 1 + name.len();
let mut j = name_end;
while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
j += 1;
}
if j >= bytes.len() || bytes[j] != b'(' {
i = name_end;
continue;
}
let body_start = j + 1;
if let Some(body_end) = match_parens(bytes, body_start) {
let body = str::from_utf8(&bytes[body_start..body_end]).unwrap_or("");
out.push(AttrHit { name, body, line });
line += newline_count(&bytes[i..=body_end]);
i = body_end + 1;
continue;
}
i = name_end;
continue;
}
i += 1;
}
out
}
fn match_attribute_keyword<'a>(
bytes: &[u8],
start: usize,
names: &'a [&'a str],
) -> Option<&'a str> {
for name in names {
let nb = name.as_bytes();
if start + nb.len() > bytes.len() {
continue;
}
if &bytes[start..start + nb.len()] != nb {
continue;
}
let after = start + nb.len();
let next = bytes.get(after).copied().unwrap_or(b' ');
if !is_attribute_name_char(next) {
return Some(name);
}
}
None
}
fn is_attribute_name_char(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn match_parens(bytes: &[u8], start: usize) -> Option<usize> {
let mut depth = 1i32;
let mut i = start;
while i < bytes.len() && depth > 0 {
let b = bytes[i];
match b {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return Some(i);
}
}
b'"' | b'~' => {
i += string_span(bytes, i).unwrap_or(1);
continue;
}
b'$' => {
i += skip_char_literal_span(bytes, i);
continue;
}
b'\'' => {
i += quoted_atom_span(bytes, i).unwrap_or(1);
continue;
}
b'%' => {
while i < bytes.len() && bytes[i] != b'\n' {
i += 1;
}
continue;
}
_ => {}
}
i += 1;
}
None
}
fn newline_count(span: &[u8]) -> u32 {
let mut n = 0;
for &b in span {
if b == b'\n' {
n += 1;
}
}
n
}
fn parse_single_atom_argument(body: &str) -> Option<String> {
let trimmed = body.trim();
if trimmed.is_empty() {
return None;
}
let mut chars = trimmed.chars();
let first = chars.next()?;
if first == '\'' {
let end = trimmed[1..].find('\'')?;
return Some(trimmed[1..=end].to_owned());
}
if !first.is_ascii_lowercase() {
return None;
}
let mut end = 1;
for c in chars {
if c.is_ascii_alphanumeric() || c == '_' || c == '@' {
end += c.len_utf8();
continue;
}
break;
}
Some(trimmed[..end].to_owned())
}
fn parse_single_string_argument(body: &str) -> Option<String> {
let trimmed = body.trim();
let first = trimmed.chars().next()?;
if first != '"' {
return None;
}
let bytes = trimmed.as_bytes();
let mut i = 1usize;
let mut out = String::new();
while i < bytes.len() {
let b = bytes[i];
if b == b'\\' && i + 1 < bytes.len() {
out.push(bytes[i + 1] as char);
i += 2;
continue;
}
if b == b'"' {
return Some(out);
}
out.push(b as char);
i += 1;
}
None
}