use std::ops::ControlFlow;
use php_ast::visitor::{Visitor, walk_expr};
use php_ast::{Expr, ExprKind, Span};
use tower_lsp_server::ls_types::{Position, Range};
use crate::document::ast::ParsedDoc;
use crate::text::utf16_offset_to_byte;
struct StringArgCall {
token_span: Span,
content: String,
content_span: Span,
}
pub(crate) fn content_span(source: &str, span: Span) -> Option<Span> {
let text = source.get(span.start as usize..span.end as usize)?;
let bytes = text.as_bytes();
let quote = *bytes.iter().find(|&&b| b == b'\'' || b == b'"')?;
let start_rel = bytes.iter().position(|&b| b == quote)?;
let end_rel = bytes.iter().rposition(|&b| b == quote)?;
if end_rel <= start_rel {
return None;
}
Some(Span::new(
span.start + start_rel as u32 + 1,
span.start + end_rel as u32,
))
}
fn collect_string_arg_calls(doc: &ParsedDoc, names: &[&str]) -> Vec<StringArgCall> {
struct Collector<'a> {
source: &'a str,
names: &'a [&'a str],
out: Vec<StringArgCall>,
}
impl<'arena, 'src> Visitor<'arena, 'src> for Collector<'_> {
fn visit_expr(&mut self, expr: &Expr<'arena, 'src>) -> ControlFlow<()> {
if let ExprKind::FunctionCall(f) = &expr.kind
&& let Some(name) = f.name.name_str()
&& self.names.iter().any(|n| n.eq_ignore_ascii_case(name))
&& let Some(arg) = f.args.first()
&& arg.name.is_none()
&& !arg.unpack
&& let Some(arg_value) = &arg.value
&& let ExprKind::String(s) = &arg_value.kind
&& let Some(cspan) = content_span(self.source, arg_value.span)
{
self.out.push(StringArgCall {
token_span: arg_value.span,
content: (*s).to_string(),
content_span: cspan,
});
}
walk_expr(self, expr)
}
}
let mut collector = Collector {
source: doc.source(),
names,
out: Vec::new(),
};
for stmt in doc.program().stmts.iter() {
let _ = collector.visit_stmt(stmt);
}
collector.out
}
pub(crate) fn call_string_arg(
doc: &ParsedDoc,
position: Position,
names: &[&str],
) -> Option<(String, Range)> {
let sv = doc.view();
let offset = sv.byte_of_position(position);
collect_string_arg_calls(doc, names)
.into_iter()
.find(|c| c.token_span.start <= offset && offset < c.token_span.end)
.map(|c| (c.content, sv.range_of(c.content_span)))
}
pub(crate) fn call_string_prefix(
source: &str,
position: Position,
names: &[&str],
) -> Option<String> {
let lines: Vec<&str> = source.lines().collect();
let line = *lines.get(position.line as usize)?;
let byte_col = utf16_offset_to_byte(line, position.character as usize);
let before = &line[..byte_col];
let quote_pos = before.rfind(['\'', '"'])?;
if !preceded_by_call_wrapped(&lines, position.line as usize, &before[..quote_pos], names) {
return None;
}
Some(before[quote_pos + 1..].to_string())
}
pub(crate) fn find_call_sites(doc: &ParsedDoc, names: &[&str], target: &str) -> Vec<Range> {
let sv = doc.view();
collect_string_arg_calls(doc, names)
.into_iter()
.filter(|c| c.content == target)
.map(|c| sv.range_of(c.content_span))
.collect()
}
pub(crate) fn find_all_calls(doc: &ParsedDoc, names: &[&str]) -> Vec<(String, Range)> {
let sv = doc.view();
collect_string_arg_calls(doc, names)
.into_iter()
.map(|c| (c.content, sv.range_of(c.content_span)))
.collect()
}
fn preceded_by_call_wrapped(
lines: &[&str],
line_idx: usize,
before_quote: &str,
names: &[&str],
) -> bool {
if preceded_by_call(before_quote, names) {
return true;
}
if !before_quote.trim().is_empty() {
return false;
}
let mut i = line_idx;
while i > 0 {
i -= 1;
let prev = lines[i];
if prev.trim().is_empty() {
continue;
}
return preceded_by_call(prev, names);
}
false
}
fn preceded_by_call(before_quote: &str, names: &[&str]) -> bool {
let Some(rest) = before_quote.trim_end().strip_suffix('(') else {
return false;
};
let rest = rest.trim_end();
names.iter().any(|name| {
rest.len() >= name.len()
&& rest[rest.len() - name.len()..].eq_ignore_ascii_case(name)
&& word_boundary_before(rest, rest.len() - name.len())
})
}
fn word_boundary_before(s: &str, idx: usize) -> bool {
idx == 0 || !matches!(s.as_bytes()[idx - 1], b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_')
}
#[cfg(test)]
mod tests {
use super::*;
const ENV: &[&str] = &["env"];
fn parse(src: &str) -> ParsedDoc {
ParsedDoc::parse(src.to_string())
}
#[test]
fn call_string_arg_matches_env_call() {
let doc = parse("<?php\n$x = env('APP_NAME');\n");
let pos = Position {
line: 1,
character: 15,
};
let (content, range) = call_string_arg(&doc, pos, ENV).unwrap();
assert_eq!(content, "APP_NAME");
assert_eq!(
range.start,
Position {
line: 1,
character: 10
}
);
assert_eq!(
range.end,
Position {
line: 1,
character: 18
}
);
}
#[test]
fn call_string_arg_rejects_unrelated_call() {
let doc = parse("<?php\n$x = getenv('APP_NAME');\n");
let pos = Position {
line: 1,
character: 18,
};
assert!(call_string_arg(&doc, pos, ENV).is_none());
}
#[test]
fn call_string_arg_rejects_plain_string_containing_pattern_textually() {
let doc = parse("<?php\n$x = 'env(APP_NAME)';\n");
let pos = Position {
line: 1,
character: 12,
};
assert!(call_string_arg(&doc, pos, ENV).is_none());
}
#[test]
fn call_string_arg_allows_whitespace_before_paren_and_quote() {
let doc = parse("<?php\n$x = env( 'APP_NAME' );\n");
let pos = Position {
line: 1,
character: 16,
};
let (content, _) = call_string_arg(&doc, pos, ENV).unwrap();
assert_eq!(content, "APP_NAME");
}
#[test]
fn call_string_prefix_returns_typed_text() {
let src = "<?php\nenv('APP_N";
let pos = Position {
line: 1,
character: 11,
};
assert_eq!(call_string_prefix(src, pos, ENV).as_deref(), Some("APP_N"));
}
#[test]
fn call_string_prefix_none_for_other_call() {
let src = "<?php\nconfig('APP_N";
let pos = Position {
line: 1,
character: 14,
};
assert!(call_string_prefix(src, pos, ENV).is_none());
}
#[test]
fn call_string_prefix_empty_right_after_quote() {
let src = "<?php\nenv('";
let pos = Position {
line: 1,
character: 5,
};
assert_eq!(call_string_prefix(src, pos, ENV).as_deref(), Some(""));
}
#[test]
fn find_call_sites_collects_every_matching_call_across_lines() {
let doc =
parse("<?php\n$a = env('APP_NAME');\n$b = env('APP_NAME');\n$c = env('OTHER');\n");
let sites = find_call_sites(&doc, ENV, "APP_NAME");
assert_eq!(sites.len(), 2);
assert_eq!(sites[0].start.line, 1);
assert_eq!(sites[1].start.line, 2);
}
#[test]
fn find_call_sites_ignores_unrelated_calls_and_keys() {
let doc = parse("<?php\n$a = getenv('APP_NAME');\n$b = env('OTHER');\n");
assert!(find_call_sites(&doc, ENV, "APP_NAME").is_empty());
}
#[test]
fn find_call_sites_empty_for_no_matches() {
let doc = parse("<?php\necho 'hello';\n");
assert!(find_call_sites(&doc, ENV, "APP_NAME").is_empty());
}
#[test]
fn call_string_arg_matches_wrapped_call() {
let doc = parse("<?php\nenv(\n 'APP_NAME'\n);\n");
let pos = Position {
line: 2,
character: 8,
};
let (content, _) = call_string_arg(&doc, pos, ENV).unwrap();
assert_eq!(content, "APP_NAME");
}
#[test]
fn call_string_arg_wrapped_call_skips_blank_lines() {
let doc = parse("<?php\nenv(\n\n 'APP_NAME'\n);\n");
let pos = Position {
line: 3,
character: 8,
};
let (content, _) = call_string_arg(&doc, pos, ENV).unwrap();
assert_eq!(content, "APP_NAME");
}
#[test]
fn call_string_arg_wrapped_call_rejects_unrelated_call() {
let doc = parse("<?php\ngetenv(\n 'APP_NAME'\n);\n");
let pos = Position {
line: 2,
character: 8,
};
assert!(call_string_arg(&doc, pos, ENV).is_none());
}
#[test]
fn find_call_sites_matches_wrapped_call() {
let doc = parse("<?php\nenv(\n 'APP_NAME'\n);\n");
let sites = find_call_sites(&doc, ENV, "APP_NAME");
assert_eq!(sites.len(), 1);
assert_eq!(sites[0].start.line, 2);
}
#[test]
fn find_all_calls_returns_every_call_regardless_of_content() {
let doc =
parse("<?php\n$a = env('APP_NAME');\n$b = env('DB_HOST');\n$c = getenv('OTHER');\n");
let calls = find_all_calls(&doc, ENV);
let contents: Vec<&str> = calls.iter().map(|(c, _)| c.as_str()).collect();
assert_eq!(contents, vec!["APP_NAME", "DB_HOST"]);
}
}