use weave_core::binding::has_definition;
fn family(path: &str) -> Option<&'static str> {
let ext = path.rsplit('.').next()?;
Some(match ext {
"py" | "pyi" => "py",
"js" | "jsx" | "mjs" | "cjs" | "ts" | "tsx" | "mts" | "cts" => "js",
"rs" => "rs",
"go" => "go",
"java" => "java",
_ => return None,
})
}
pub(crate) fn same_family(a: &str, b: &str) -> bool {
matches!((family(a), family(b)), (Some(x), Some(y)) if x == y)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Signature {
pub text: String,
pub params: usize,
}
pub(crate) fn params(path: &str, name: &str, content: &str) -> Option<Signature> {
let lang = family(path)?;
let open = signature_open_paren(content, name)?;
let (args, close) = split_args(content, open, true, lang == "rs")?;
if args.len() == 1 && args[0].is_empty() {
return Some(sig(content, open, close, 0));
}
for (i, a) in args.iter().enumerate() {
let a = a.trim();
if a.is_empty() {
if i + 1 == args.len() {
continue;
}
return None;
}
if a.contains('=') || a.contains("...") || a.starts_with('*') || a == "/" {
return None;
}
match lang {
"py" if i == 0 && (a == "self" || a == "cls") => return None,
"rs" if a.trim_start_matches(['&', ' ']).starts_with("self")
|| a.starts_with("mut self") =>
{
return None
}
"js" if a
.split(':')
.next()
.is_some_and(|n| n.trim_end().ends_with('?')) =>
{
return None
}
_ => {}
}
}
let n = args.iter().filter(|a| !a.trim().is_empty()).count();
Some(sig(content, open, close, n))
}
fn sig(content: &str, open: usize, close: usize, params: usize) -> Signature {
let start = content[..open].rfind('\n').map_or(0, |i| i + 1);
let text = content[start..=close]
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
Signature { text, params }
}
fn signature_open_paren(content: &str, name: &str) -> Option<usize> {
let bytes = content.as_bytes();
let mut from = 0;
let at = loop {
let i = from + content[from..].find(name)?;
let before = content[..i].chars().next_back();
let after = content[i + name.len()..].chars().next();
if before.is_none_or(|c| !ident(c) && c != '.') && after.is_none_or(|c| !ident(c)) {
break i + name.len();
}
from = i + name.chars().next().map_or(1, char::len_utf8);
};
let mut j = skip_ws(bytes, at);
if let Some(&b) = bytes.get(j) {
if b == b'<' || b == b'[' {
j = skip_ws(bytes, close_of(bytes, j)? + 1);
}
}
if bytes.get(j) == Some(&b'=') && bytes.get(j + 1) != Some(&b'=') {
j = skip_ws(bytes, j + 1);
for kw in ["async", "function"] {
if content[j..].starts_with(kw) {
j = skip_ws(bytes, j + kw.len());
}
}
}
(bytes.get(j) == Some(&b'(')).then_some(j)
}
fn ident(c: char) -> bool {
c.is_alphanumeric() || c == '_' || c == '$'
}
fn skip_ws(bytes: &[u8], mut i: usize) -> usize {
while bytes.get(i).is_some_and(u8::is_ascii_whitespace) {
i += 1;
}
i
}
fn close_of(bytes: &[u8], open: usize) -> Option<usize> {
let mut depth = 0i32;
for (k, &b) in bytes.iter().enumerate().skip(open) {
match b {
b'<' | b'[' | b'(' | b'{' => depth += 1,
b'>' if k > 0 && bytes[k - 1] == b'-' => {}
b'>' | b']' | b')' | b'}' => {
depth -= 1;
if depth == 0 {
return Some(k);
}
}
_ => {}
}
}
None
}
fn split_args(content: &str, open: usize, angles: bool, rust: bool) -> Option<(Vec<&str>, usize)> {
let bytes = content.as_bytes();
let mut depth = 0i32;
let mut seg = open + 1;
let mut out = Vec::new();
let mut k = open;
while k < bytes.len() {
let b = bytes[k];
match b {
b'\''
if rust
&& bytes
.get(k + 1)
.is_some_and(|c| c.is_ascii_alphabetic() || *c == b'_')
&& bytes.get(k + 2) != Some(&b'\'') => {}
b'"' | b'\'' | b'`' => {
k = string_end(bytes, k)?;
}
b'(' | b'[' | b'{' => depth += 1,
b'<' if angles => depth += 1,
b'>' if angles && k > 0 && matches!(bytes[k - 1], b'-' | b'=') => {}
b'>' if angles => depth -= 1,
b')' | b']' | b'}' => {
depth -= 1;
if depth == 0 {
out.push(content[seg..k].trim());
return Some((out, k));
}
}
b',' if depth == 1 => {
out.push(content[seg..k].trim());
seg = k + 1;
}
_ => {}
}
k += 1;
}
None
}
fn string_end(bytes: &[u8], open: usize) -> Option<usize> {
let q = bytes[open];
let mut k = open + 1;
while k < bytes.len() {
match bytes[k] {
b'\\' => k += 1,
b'\n' if q != b'`' => return None,
c if c == q => return Some(k),
_ => {}
}
k += 1;
}
None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct CallSite {
pub line: usize,
pub text: String,
pub args: Option<usize>,
}
pub(crate) fn call_sites(path: &str, content: &str, name: &str) -> Vec<CallSite> {
let mut out = Vec::new();
if name.is_empty() || !content.contains(name) {
return out;
}
let rust = family(path) == Some("rs");
let mut line_start = 0usize;
for (n, line) in content.split_inclusive('\n').enumerate() {
let here = line_start;
line_start += line.len();
let trimmed = line.trim_start();
if trimmed.starts_with("//") || trimmed.starts_with('#') || trimmed.starts_with('*') {
continue;
}
if has_definition(line, name) {
continue;
}
let mut from = 0usize;
while let Some(rel) = line[from..].find(name) {
let i = from + rel;
from = i + name.chars().next().map_or(1, char::len_utf8);
let before_ok = line[..i]
.chars()
.next_back()
.is_none_or(|c| !ident(c) && c != '.');
let quotes = line[..i]
.bytes()
.filter(|b| matches!(b, b'"' | b'\'' | b'`'))
.count();
let after = i + name.len();
let rest = &line[after..];
let paren = rest.len() - rest.trim_start().len();
if !before_ok || quotes % 2 == 1 || !rest.trim_start().starts_with('(') {
continue;
}
let open = here + after + paren;
let args = split_args(content, open, false, rust).and_then(|(segs, close)| {
let text = &content[here + i..=close];
let n = count_args(&segs)?;
Some((n, text.split_whitespace().collect::<Vec<_>>().join(" ")))
});
out.push(CallSite {
line: n + 1,
text: args
.as_ref()
.map_or_else(|| line.trim().to_string(), |(_, t)| t.clone()),
args: args.map(|(n, _)| n),
});
}
}
out
}
fn count_args(segs: &[&str]) -> Option<usize> {
if segs.len() == 1 && segs[0].is_empty() {
return Some(0);
}
let mut n = 0;
for (i, s) in segs.iter().enumerate() {
if s.is_empty() {
if i + 1 == segs.len() {
continue;
}
return None;
}
if s.starts_with('*') || s.starts_with("...") || s.ends_with("...") {
return None;
}
n += 1;
}
Some(n)
}
#[cfg(test)]
mod tests {
use super::*;
fn p(path: &str, name: &str, src: &str) -> Option<usize> {
params(path, name, src).map(|s| s.params)
}
#[test]
fn exact_parameter_lists_count_and_flexible_ones_refuse() {
assert_eq!(p("a.py", "f", "def f(a, b):\n pass\n"), Some(2));
assert_eq!(p("a.py", "f", "def f():\n pass\n"), Some(0));
assert_eq!(p("a.py", "f", "def f(a, b=1):\n pass\n"), None);
assert_eq!(p("a.py", "f", "def f(a, *rest):\n pass\n"), None);
assert_eq!(
p(
"a.ts",
"f",
"export function f(a: Map<K, V>, b?: number) {}"
),
None
);
assert_eq!(
p(
"a.ts",
"f",
"const f = async (a: Map<K, V>, b: number) => a;"
),
Some(2)
);
assert_eq!(
p(
"a.rs",
"f",
"pub fn f<T: Fn(u8, u8) -> u8>(a: T, b: Vec<(u8, u8)>) {}"
),
Some(2)
);
assert_eq!(p("a.go", "f", "func f(a, b int, c ...string) {}"), None);
assert_eq!(
p("a.go", "f", "func f(a, b int) error { return nil }"),
Some(2)
);
assert_eq!(p("a.rb", "f", "def f(a)\nend\n"), None);
}
#[test]
fn call_sites_count_arguments_and_refuse_splats() {
let src = "x = f(1, g(2, 3), 'a,b')\ny = obj.f(1)\nz = f(*args)\nw = f()\n";
let calls = call_sites("a.py", src, "f");
let counts: Vec<_> = calls.iter().map(|c| (c.line, c.args)).collect();
assert_eq!(counts, vec![(1, Some(3)), (3, None), (4, Some(0))]);
}
}