1use std::collections::{HashMap, HashSet};
5
6use sqlparser::ast::{ObjectName, Statement, TableFactor, TableWithJoins};
7use sqlparser::dialect::GenericDialect;
8use sqlparser::parser::Parser;
9
10use super::{Binder, Value};
11use crate::facts::{Args, ContractFact, FileFacts, ImportItem, RefKind, TemplatePart, ValueExpr};
12use crate::model::{
13 EdgeKind, GlobalSymbolId, Language, Position, Range, RepoId, Symbol, SymbolKind,
14};
15
16const HTTP_VERBS: &[&str] = &["get", "post", "put", "patch", "delete", "head", "options"];
17const HTTP_CLIENTS: &[&str] = &[
18 "httpx.Client",
19 "httpx.AsyncClient",
20 "requests.Session",
21 "aiohttp.ClientSession",
22];
23const HTTP_MODULES: &[&str] = &["httpx", "requests", "axios", "ky", "got"];
24const ROUTERS: &[&str] = &[
25 "fastapi.APIRouter",
26 "fastapi.FastAPI",
27 "express",
28 "express.Router",
29];
30const DB_READS: &[&str] = &["get", "query", "select", "scalars"];
31const DB_WRITES: &[&str] = &["add", "merge", "delete", "insert", "update"];
32const PASS_THROUGH: &[&str] = &["Path", "PurePath", "PurePosixPath", "str", "URL", "HttpUrl"];
34const RECORDS: &[&str] = &["AttrDict", "dict", "SimpleNamespace", "Namespace"];
36
37pub use comindly_api::graph::CONTRACTS;
38
39#[derive(Clone)]
40enum Client {
41 Http,
42 Aws(String),
43}
44
45pub(super) struct Linked {
46 pub symbols: Vec<Symbol>,
47 pub edges: Vec<(String, String, EdgeKind)>,
48}
49
50pub(super) fn link(b: &Binder, facts: &[FileFacts], calls: &[(String, String)]) -> Linked {
51 let mut cx = Cx::new(b, facts);
52 cx.performing(facts, calls);
53 let mut out = Linked {
54 symbols: Vec::new(),
55 edges: Vec::new(),
56 };
57 let mut emit = |src: &str, key: String, kind: EdgeKind| {
58 if b.file_facts_of(src).is_some_and(|f| test_path(&f.path)) {
60 return;
61 }
62 out.edges
63 .push((src.to_string(), contract_id(&key).render(), kind));
64 if !out.symbols.iter().any(|s: &Symbol| s.name == key) {
65 out.symbols.push(contract_symbol(&key));
66 }
67 };
68
69 for f in facts {
70 for c in &f.contracts {
71 match c {
72 ContractFact::Table { class, name } => {
73 emit(class, format!("table:{name}"), EdgeKind::Defines)
74 }
75 ContractFact::Route {
76 handler,
77 receiver,
78 method,
79 path,
80 client,
81 ..
82 } => {
83 let at = cx.b.parent.get(handler).unwrap_or(&f.file_id);
85 let Some(p) = cx.value(path, at, 0) else {
86 continue;
87 };
88 let fulls = if receiver.is_empty() {
90 vec![p]
91 } else {
92 let Some(router) = cx.var_of(receiver, &f.file_id, f) else {
93 continue;
94 };
95 if !cx.routers.contains_key(&router) {
96 continue;
97 }
98 cx.prefixes(&router, 0)
99 .into_iter()
100 .map(|pre| format!("{pre}{p}"))
101 .collect()
102 };
103 let kind = if *client {
104 EdgeKind::Consumes
105 } else {
106 EdgeKind::Serves
107 };
108 for full in fulls {
109 emit(
110 handler,
111 format!("http:{method} {}", http_path(&full)),
112 kind.clone(),
113 );
114 }
115 }
116 ContractFact::Handle {
117 from,
118 receiver,
119 method,
120 path,
121 handler,
122 ..
123 } => {
124 let Some(p) = cx.value(path, from, 0) else {
125 continue;
126 };
127 let p = format!("{}{p}", cx.group_prefix(from, receiver, 0));
128 let src = match cx.b.resolve_path(handler, from, f, 0) {
129 Some(Value::Sym(id)) if !handler.is_empty() => id,
130 _ => from.clone(),
131 };
132 emit(
133 &src,
134 format!("http:{method} {}", http_path(&p)),
135 EdgeKind::Serves,
136 );
137 }
138 ContractFact::Op {
139 from,
140 receiver,
141 method,
142 args,
143 ..
144 } => cx.op(f, from, receiver, method, args, &mut emit),
145 ContractFact::Construct { .. } | ContractFact::Infra { .. } => {}
146 }
147 }
148 }
149 cx.propagate(facts, &mut emit);
150 link_infra(b, facts, &mut out);
151 suffix_match(&mut out);
152 let mut seen = std::collections::HashSet::new();
153 out.edges
154 .retain(|(a, b, k)| seen.insert((a.clone(), b.clone(), k.as_str())));
155 out
156}
157
158fn link_infra(b: &Binder, facts: &[FileFacts], out: &mut Linked) {
161 let repo_of = |id: &str| b.file_facts_of(id).map(|f| f.repo.clone());
162 let mut used: Vec<(String, String)> = Vec::new(); let names: HashMap<String, String> = out
164 .symbols
165 .iter()
166 .map(|s| (s.id.render(), s.name.clone()))
167 .collect();
168 for (src, dst, _) in &out.edges {
169 if let (Some(r), Some(n)) = (repo_of(src), names.get(dst)) {
170 used.push((r, n.clone()));
171 }
172 }
173 for f in facts {
175 for c in &f.contracts {
176 if let ContractFact::Infra {
177 declares,
178 subscribes,
179 } = c
180 {
181 for r in std::iter::once(declares)
182 .chain(subscribes)
183 .filter(|r| !r.logical)
184 {
185 used.push((String::new(), format!("{}:{}", r.scheme, untier(&r.name))));
186 }
187 }
188 }
189 }
190 let key = |r: &crate::facts::InfraRef, repo: &str| -> String {
191 let full = format!("{}:{}", r.scheme, untier(&r.name));
192 if !r.logical {
193 return full;
194 }
195 let want = name_tokens(&untier(&r.name));
196 let own = name_tokens(repo);
197 let contains = |have: &[String], part: &[String]| {
198 !part.is_empty() && have.windows(part.len()).any(|w| w == part)
199 };
200 let mut hits: Vec<&String> = used
201 .iter()
202 .filter(|(_, n)| n.starts_with(&format!("{}:", r.scheme)))
203 .filter(|(rr, n)| {
204 let have = name_tokens(&n[r.scheme.len() + 1..]);
205 let ends = have.len() >= want.len() && have[have.len() - want.len()..] == want[..];
206 ends && (rr == repo || contains(&have, &own))
208 })
209 .map(|(_, n)| n)
210 .collect();
211 hits.sort();
212 hits.dedup();
213 match hits.as_slice() {
214 [one] => one.to_string(),
215 _ => full,
216 }
217 };
218 let add = |src: String, k: String, kind: EdgeKind, out: &mut Linked| {
219 let e = (src, contract_id(&k).render(), kind);
220 if !out.edges.contains(&e) {
221 out.edges.push(e);
222 }
223 if !out.symbols.iter().any(|s| s.name == k) {
224 out.symbols.push(contract_symbol(&k));
225 }
226 };
227 for f in facts {
228 for c in &f.contracts {
229 let ContractFact::Infra {
230 declares,
231 subscribes,
232 } = c
233 else {
234 continue;
235 };
236 let q = key(declares, &f.repo);
237 add(f.file_id.clone(), q.clone(), EdgeKind::Defines, out);
238 if let Some(t) = subscribes {
239 let t = key(t, &f.repo);
240 add(contract_id(&q).render(), t, EdgeKind::Subscribes, out);
241 }
242 }
243 }
244}
245
246fn suffix_match(out: &mut Linked) {
249 let ids: HashMap<String, String> = out
250 .symbols
251 .iter()
252 .map(|s| (s.id.render(), s.name.clone()))
253 .collect();
254 let served: Vec<&String> = out
255 .edges
256 .iter()
257 .filter(|(_, _, k)| *k == EdgeKind::Serves)
258 .filter_map(|(_, d, _)| ids.get(d))
259 .collect();
260 let mut redirect: HashMap<String, String> = HashMap::new();
261 for (id, name) in &ids {
262 let Some((verb, path)) = name.strip_prefix("http:").and_then(|r| r.split_once(' ')) else {
263 continue;
264 };
265 if served.contains(&name) || path.matches('/').count() < 2 {
266 continue;
267 }
268 let cands: Vec<&&String> = served
269 .iter()
270 .filter(|p| {
271 p.strip_prefix("http:")
272 .and_then(|r| r.split_once(' '))
273 .is_some_and(|(v, pp)| v == verb && pp != path && pp.ends_with(path))
274 })
275 .collect();
276 let mut uniq: Vec<&String> = cands.into_iter().copied().collect();
277 uniq.sort();
278 uniq.dedup();
279 if let [target] = uniq.as_slice() {
280 redirect.insert(id.clone(), contract_id(target).render());
281 }
282 }
283 for e in &mut out.edges {
284 if let Some(t) = redirect.get(&e.1) {
285 e.1 = t.clone();
286 }
287 }
288 out.symbols
289 .retain(|s| !redirect.contains_key(&s.id.render()));
290}
291
292fn router_classes(cx: &Cx, facts: &[FileFacts]) -> HashSet<String> {
294 let mut out = HashSet::new();
295 for f in facts {
296 for r in f.refs.iter().filter(|r| r.kind == RefKind::Inherits) {
297 let scope =
298 cx.b.parent
299 .get(&r.from)
300 .cloned()
301 .unwrap_or(f.file_id.clone());
302 if cx
303 .external(&r.path, &scope, f)
304 .is_some_and(|e| ROUTERS.contains(&e.as_str()))
305 {
306 out.insert(r.from.clone());
307 }
308 }
309 }
310 let bases = cx.b.bases.borrow();
311 loop {
312 let more: Vec<String> = bases
313 .iter()
314 .filter(|(c, bs)| !out.contains(*c) && bs.iter().any(|b| out.contains(b)))
315 .map(|(c, _)| c.clone())
316 .collect();
317 if more.is_empty() {
318 return out;
319 }
320 out.extend(more);
321 }
322}
323
324struct Cx<'b, 'a> {
325 b: &'b Binder<'a>,
326 values: HashMap<(&'a str, &'a str), &'a ValueExpr>,
327 clients: HashMap<(String, String), Client>,
329 routers: HashMap<String, String>,
331 mounts: HashMap<String, Vec<(String, String)>>,
333 http_types: std::collections::HashSet<String>,
335 groups: HashMap<(String, String), (Vec<String>, String)>,
337 config: HashMap<&'a str, Vec<(&'a str, &'a str)>>,
339 params: HashMap<&'a str, &'a [String]>,
341 summaries: std::cell::RefCell<HashMap<String, Vec<Summary>>>,
343}
344
345#[derive(Clone, Copy, PartialEq)]
346enum Scheme {
347 Sqs,
348 Sns,
349 S3,
350}
351
352#[derive(Clone, PartialEq)]
354struct Summary {
355 param: String,
356 index: usize,
357 field: Vec<String>,
359 scheme: Scheme,
360 kind: EdgeKind,
361}
362
363type Origin = (String, String, usize, Vec<String>);
365
366fn positional(names: &[String], p: &str) -> Option<usize> {
368 let i = names.iter().position(|n| n == p)?;
369 Some(if names[..i].iter().any(|n| n == "*") {
370 usize::MAX
371 } else {
372 i
373 })
374}
375
376fn scheme_key(s: Scheme, v: &str) -> String {
377 match s {
378 Scheme::Sqs => format!("sqs:{}", untier(&queue_name(v))),
379 Scheme::Sns => format!("sns:{}", untier(&arn_name(v))),
380 Scheme::S3 => format!("s3:{}", untier(&bucket(v))),
381 }
382}
383
384fn aws_spec(svc: &str, method: &str) -> Option<(&'static [&'static str], usize, Scheme, EdgeKind)> {
386 const ANY: usize = usize::MAX;
387 Some(match (svc, method) {
388 ("sqs", "send_message" | "send_message_batch") => {
389 (&["QueueUrl"], ANY, Scheme::Sqs, EdgeKind::Publishes)
390 }
391 ("sqs", "receive_message") => (&["QueueUrl"], ANY, Scheme::Sqs, EdgeKind::Subscribes),
392 ("sns", "publish" | "publish_batch") => (
393 &["TopicArn", "TargetArn"],
394 ANY,
395 Scheme::Sns,
396 EdgeKind::Publishes,
397 ),
398 ("s3", "put_object" | "delete_object" | "copy_object") => {
399 (&["Bucket"], ANY, Scheme::S3, EdgeKind::Writes)
400 }
401 ("s3", "upload_file" | "upload_fileobj") => (&["Bucket"], 1, Scheme::S3, EdgeKind::Writes),
402 ("s3", "get_object" | "head_object" | "list_objects_v2") => {
403 (&["Bucket"], ANY, Scheme::S3, EdgeKind::Reads)
404 }
405 ("s3", "download_file" | "download_fileobj") => {
406 (&["Bucket"], 0, Scheme::S3, EdgeKind::Reads)
407 }
408 _ => return None,
409 })
410}
411
412fn infer_aws(method: &str, has: impl Fn(&str) -> bool) -> Option<&'static str> {
415 ["sqs", "sns", "s3"]
416 .into_iter()
417 .find(|svc| aws_spec(svc, method).is_some_and(|(kws, _, _, _)| kws.iter().any(|k| has(k))))
418}
419
420const RESOURCE_ATTRS: &[&str] = &["arn", "url", "name", "id"];
422
423impl<'b, 'a> Cx<'b, 'a> {
424 fn new(b: &'b Binder<'a>, facts: &'a [FileFacts]) -> Self {
425 let mut cx = Cx {
426 b,
427 values: HashMap::new(),
428 clients: HashMap::new(),
429 routers: HashMap::new(),
430 mounts: HashMap::new(),
431 http_types: std::collections::HashSet::new(),
432 groups: HashMap::new(),
433 params: HashMap::new(),
434 summaries: Default::default(),
435 config: HashMap::new(),
436 };
437 for f in facts
438 .iter()
439 .filter(|f| f.file_id.starts_with(crate::configvals::SCOPE))
440 {
441 for v in &f.values {
442 if let ValueExpr::Lit(lit) = &v.value {
443 cx.config
444 .entry(f.repo.as_str())
445 .or_default()
446 .push((v.name.as_str(), lit.as_str()));
447 }
448 }
449 }
450 for f in facts {
451 for p in &f.params {
452 cx.params.insert(p.func.as_str(), p.names.as_slice());
453 }
454 }
455 for f in facts {
456 for v in &f.values {
457 cx.values
458 .insert((v.scope.as_str(), v.name.as_str()), &v.value);
459 }
460 }
461 let router_classes = router_classes(&cx, facts);
462 for f in facts {
463 for c in &f.contracts {
464 let ContractFact::Construct {
465 scope,
466 target,
467 ctor,
468 args,
469 } = c
470 else {
471 continue;
472 };
473 if f.language == Language::Go && ctor.last().is_some_and(|c| c == "Group") {
474 let prefix = args
475 .positional
476 .first()
477 .and_then(Option::as_ref)
478 .and_then(|v| cx.value(v, scope, 0))
479 .unwrap_or_default();
480 let parent = ctor[..ctor.len() - 1].to_vec();
481 if parent == [target.clone()] {
482 continue;
483 }
484 cx.groups
485 .insert((scope.clone(), target.clone()), (parent, prefix));
486 continue;
487 }
488 let ext = match cx.external(ctor, scope, f) {
489 Some(e) => e,
490 None => match cx.b.resolve_path(ctor, scope, f, 0) {
491 Some(Value::Sym(class)) if router_classes.contains(&class) => {
492 ROUTERS[0].to_string()
493 }
494 _ => continue,
495 },
496 };
497 if ROUTERS.contains(&ext.as_str()) {
498 if let Some(var) = cx.var_of(std::slice::from_ref(target), scope, f) {
499 let prefix = args
500 .get("prefix", usize::MAX)
501 .and_then(|v| cx.value(v, scope, 0))
502 .unwrap_or_default();
503 cx.routers.insert(var, prefix);
504 }
505 } else if HTTP_CLIENTS.contains(&ext.as_str()) {
506 cx.clients
507 .insert((scope.clone(), target.clone()), Client::Http);
508 } else if ext == "boto3.client" || ext == "boto3.resource" {
509 if let Some(svc) = args
510 .get("service_name", 0)
511 .and_then(|v| cx.value(v, scope, 0))
512 {
513 cx.clients
514 .insert((scope.clone(), target.clone()), Client::Aws(svc));
515 }
516 }
517 }
518 }
519 for f in facts {
520 for c in &f.contracts {
521 let ContractFact::Op {
522 from,
523 receiver,
524 method,
525 args,
526 ..
527 } = c
528 else {
529 continue;
530 };
531 let (router, prefix) = match method.as_str() {
533 "include_router" => (
534 args.positional.first(),
535 args.get("prefix", usize::MAX)
536 .and_then(|v| cx.value(v, from, 0)),
537 ),
538 "use" if args.positional.len() >= 2 => (
539 args.positional.last(),
540 args.positional
541 .first()
542 .and_then(Option::as_ref)
543 .and_then(|v| cx.value(v, from, 0)),
544 ),
545 _ => continue,
546 };
547 let parent = cx.var_of(receiver, from, f);
548 let router = match router {
549 Some(Some(ValueExpr::Name(p))) => cx.var_of(p, from, f),
550 _ => None,
551 };
552 if let (Some(parent), Some(router)) = (parent, router) {
553 cx.mounts
554 .entry(router)
555 .or_default()
556 .push((parent, prefix.unwrap_or_default()));
557 }
558 }
559 }
560 cx
561 }
562
563 fn performing(&mut self, facts: &[FileFacts], calls: &[(String, String)]) {
565 let mut doing: std::collections::HashSet<String> = std::collections::HashSet::new();
566 for f in facts
567 .iter()
568 .filter(|f| matches!(f.language, Language::TypeScript | Language::JavaScript))
569 {
570 for c in &f.contracts {
571 if let ContractFact::Op {
572 from,
573 receiver,
574 method,
575 ..
576 } = c
577 {
578 if (receiver.is_empty() && method == "fetch")
579 || self.direct_http(receiver, from, f)
580 {
581 doing.insert(from.clone());
582 }
583 }
584 }
585 }
586 let mut callers: HashMap<&str, Vec<&str>> = HashMap::new();
587 for (src, dst) in calls {
588 callers.entry(dst.as_str()).or_default().push(src.as_str());
589 }
590 let mut queue: Vec<String> = doing.iter().cloned().collect();
591 while let Some(n) = queue.pop() {
592 for c in callers.get(n.as_str()).into_iter().flatten() {
593 if doing.len() < 100_000 && doing.insert(c.to_string()) {
594 queue.push(c.to_string());
595 }
596 }
597 }
598 let mut types: Vec<String> = doing
599 .iter()
600 .filter_map(|m| self.b.parent.get(m))
601 .filter(|p| self.b.is_type(p))
602 .cloned()
603 .collect();
604 while let Some(t) = types.pop() {
605 if self.http_types.insert(t.clone()) {
606 types.extend(self.b.bases.borrow().get(&t).cloned().unwrap_or_default());
607 }
608 }
609 }
610
611 fn direct_http(&self, receiver: &[String], from: &str, f: &FileFacts) -> bool {
612 receiver.len() == 1
613 && self
614 .external(receiver, from, f)
615 .is_some_and(|m| HTTP_MODULES.contains(&m.as_str()))
616 }
617
618 fn group_prefix(&self, scope: &str, receiver: &[String], depth: u32) -> String {
620 let [name] = receiver else {
621 return String::new();
622 };
623 match self.groups.get(&(scope.to_string(), name.clone())) {
624 Some((parent, p)) if depth < 8 => {
625 format!("{}{p}", self.group_prefix(scope, parent, depth + 1))
626 }
627 _ => String::new(),
628 }
629 }
630
631 fn prefixes(&self, router: &str, depth: u32) -> Vec<String> {
634 let own = self.routers.get(router).cloned().unwrap_or_default();
635 match self.mounts.get(router) {
636 Some(ms) if depth < 8 => {
637 let mut out: Vec<String> = ms
638 .iter()
639 .flat_map(|(parent, p)| {
640 self.prefixes(parent, depth + 1)
641 .into_iter()
642 .map(move |pp| format!("{pp}{p}"))
643 })
644 .map(|pre| format!("{pre}{own}"))
645 .collect();
646 out.sort();
647 out.dedup();
648 out
649 }
650 _ => vec![own],
651 }
652 }
653
654 fn external(&self, path: &[String], scope: &str, f: &FileFacts) -> Option<String> {
656 let (head, rest) = path.split_first()?;
657 let base = match self.b.resolve_name(head, scope, f, 0) {
658 Some(Value::Module { name, .. })
659 if !self
660 .b
661 .modules
662 .contains_key(&(super::Family::Py, name.as_str())) =>
663 {
664 name
665 }
666 Some(_) => return None,
667 None => f.imports.iter().find_map(|imp| {
669 imp.items.iter().find_map(|item| match item {
670 ImportItem::Name { name, alias }
672 if imp.level == 0
673 && name == "default"
674 && alias.as_deref() == Some(head) =>
675 {
676 Some(imp.module.clone())
677 }
678 ImportItem::Name { name, alias }
679 if imp.level == 0 && alias.as_deref().unwrap_or(name) == head =>
680 {
681 Some(format!("{}.{name}", imp.module))
682 }
683 ImportItem::Module { alias: Some(a) } if a == head => Some(imp.module.clone()),
684 _ => None,
685 })
686 })?,
687 };
688 let mut parts = vec![base];
689 parts.extend(rest.iter().cloned());
690 Some(parts.join("."))
691 }
692
693 fn var_of(&self, path: &[String], scope: &str, f: &FileFacts) -> Option<String> {
695 let found = match path {
696 [name] => self.b.module_member(&f.file_id, name, true, 0),
697 _ => self.b.resolve_path(path, scope, f, 0),
698 };
699 match found? {
700 Value::Sym(id) => Some(id),
701 _ => None,
702 }
703 }
704
705 fn client(&self, receiver: &[String], from: &str, f: &FileFacts) -> Option<Client> {
706 if let ([recv, attr], true) = (receiver, receiver.first().is_some_and(|r| r == "self")) {
707 let class = self.b.enclosing_class(from)?;
708 return self
709 .clients
710 .get(&(class, format!("{recv}.{attr}")))
711 .cloned();
712 }
713 if let [name] = receiver {
714 for s in self.b.scope_chain(from) {
715 if let Some(c) = self.clients.get(&(s, name.clone())) {
716 return Some(c.clone());
717 }
718 }
719 }
720 let id = self.var_of(receiver, from, f)?;
722 let var = self.b.sym(&id)?;
723 let scope = self.b.parent.get(&id)?;
724 self.clients
725 .get(&(scope.clone(), var.name.clone()))
726 .cloned()
727 }
728
729 fn op(
730 &self,
731 f: &FileFacts,
732 from: &str,
733 receiver: &[String],
734 method: &str,
735 args: &Args,
736 emit: &mut impl FnMut(&str, String, EdgeKind),
737 ) {
738 if method == "text" && receiver.is_empty() {
739 if let Some(ValueExpr::Lit(sql)) = args.positional.first().and_then(Option::as_ref) {
740 for (table, write) in sql_tables(sql) {
741 let kind = if write {
742 EdgeKind::Writes
743 } else {
744 EdgeKind::Reads
745 };
746 emit(from, format!("table:{table}"), kind);
747 }
748 }
749 return;
750 }
751 if matches!(method, "from_" | "join" | "into" | "update") {
753 if let Some(table) = args
754 .positional
755 .first()
756 .and_then(Option::as_ref)
757 .and_then(|v| self.pypika_table(v, from))
758 {
759 let kind = if matches!(method, "into" | "update") {
760 EdgeKind::Writes
761 } else {
762 EdgeKind::Reads
763 };
764 emit(from, format!("table:{table}"), kind);
765 return;
766 }
767 }
768 if let Some(table) = self.model_table(args, from, f) {
769 if DB_READS.contains(&method) {
770 emit(from, format!("table:{table}"), EdgeKind::Reads);
771 return;
772 }
773 if DB_WRITES.contains(&method) {
774 emit(from, format!("table:{table}"), EdgeKind::Writes);
775 return;
776 }
777 }
778 if HTTP_VERBS.contains(&method)
780 && matches!(f.language, Language::TypeScript | Language::JavaScript)
781 && args.positional.len() >= 2
782 {
783 if let Some(router) = self
784 .var_of(receiver, from, f)
785 .filter(|r| self.routers.contains_key(r))
786 {
787 let path = args.positional.first().and_then(Option::as_ref);
788 if let Some(p) = path.and_then(|p| self.value(p, from, 0)) {
789 let handler = match args.positional.last() {
790 Some(Some(ValueExpr::Name(h))) => {
791 match self.b.resolve_path(h, from, f, 0) {
792 Some(Value::Sym(id)) => id,
793 _ => from.to_string(),
794 }
795 }
796 _ => from.to_string(),
797 };
798 for pre in self.prefixes(&router, 0) {
799 emit(
800 &handler,
801 format!(
802 "http:{} {}",
803 method.to_uppercase(),
804 http_path(&format!("{pre}{p}"))
805 ),
806 EdgeKind::Serves,
807 );
808 }
809 }
810 return;
811 }
812 }
813 if receiver.is_empty() && method == "fetch" {
814 let url = args.positional.first().and_then(Option::as_ref);
815 let verb = match args.positional.get(1).and_then(Option::as_ref) {
816 Some(init) => self
817 .field(init, from, "method", 0)
818 .and_then(|(v, at)| self.value(&v, &at, 0))
819 .unwrap_or_else(|| "GET".into()),
820 None => "GET".into(),
821 };
822 if let Some(url) = url.and_then(|u| self.value(u, from, 0)) {
823 emit(
824 from,
825 format!("http:{} {}", verb.to_uppercase(), http_path(&url)),
826 EdgeKind::Consumes,
827 );
828 }
829 return;
830 }
831 if matches!(f.language, Language::Java | Language::Kotlin)
832 && receiver == [crate::lang::spring::WEBCLIENT]
833 {
834 let url = args.positional.first().and_then(Option::as_ref);
835 if let Some(url) = url.and_then(|u| self.value(u, from, 0)) {
836 emit(
837 from,
838 format!("http:{method} {}", http_path(&url)),
839 EdgeKind::Consumes,
840 );
841 }
842 return;
843 }
844 if matches!(f.language, Language::Java | Language::Kotlin) {
845 if let Some((_, verb)) = crate::lang::spring::REST_TEMPLATE
846 .iter()
847 .find(|(m, _)| *m == method)
848 {
849 let verb = match (verb.is_empty(), args.positional.get(1)) {
851 (false, _) => Some(verb.to_string()),
852 (true, Some(Some(ValueExpr::Name(p)))) => p.last().cloned(),
853 _ => None,
854 };
855 let url = args.positional.first().and_then(Option::as_ref);
856 if let (Some(verb), Some(url)) = (verb, url.and_then(|u| self.value(u, from, 0))) {
857 emit(
858 from,
859 format!("http:{} {}", verb.to_uppercase(), http_path(&url)),
860 EdgeKind::Consumes,
861 );
862 }
863 return;
864 }
865 }
866 if f.language == Language::Go {
867 if let Some((verb, url)) = go_request(receiver, method, args) {
868 if let Some(url) = self.value(url, from, 0) {
869 emit(
870 from,
871 format!("http:{verb} {}", http_path(&url)),
872 EdgeKind::Consumes,
873 );
874 }
875 return;
876 }
877 }
878 if f.language == Language::Rust && HTTP_VERBS.contains(&method) {
879 if let Some(url) = args
881 .positional
882 .first()
883 .and_then(Option::as_ref)
884 .and_then(|u| self.value(u, from, 0))
885 .filter(|u| u.starts_with("http") || u.starts_with('/'))
886 {
887 emit(
888 from,
889 format!("http:{} {}", method.to_uppercase(), http_path(&url)),
890 EdgeKind::Consumes,
891 );
892 }
893 return;
894 }
895 let direct_http = self.direct_http(receiver, from, f);
896 let client = if direct_http {
897 Some(Client::Http)
898 } else {
899 self.client(receiver, from, f)
900 .or_else(|| self.wrapper(receiver, method, from, f))
901 };
902 match client {
903 Some(Client::Http) if HTTP_VERBS.contains(&method) || method == "request" => {
904 let (verb, url) = if method == "request" {
905 (
906 args.get("method", 0).and_then(|v| self.value(v, from, 0)),
907 args.get("url", 1),
908 )
909 } else {
910 (Some(method.to_string()), args.get("url", 0))
911 };
912 if let (Some(verb), Some(url)) = (verb, url.and_then(|u| self.value(u, from, 0))) {
913 emit(
914 from,
915 format!("http:{} {}", verb.to_uppercase(), http_path(&url)),
916 EdgeKind::Consumes,
917 );
918 }
919 }
920 Some(Client::Aws(svc)) => {
921 if let Some((key, kind)) = self.aws(&svc, method, args, from) {
922 emit(from, key, kind);
923 }
924 }
925 None => {
926 if let Some(svc) = infer_aws(method, |k| self.kwarg(args, k, from).is_some()) {
927 if let Some((key, kind)) = self.aws(svc, method, args, from) {
928 emit(from, key, kind);
929 }
930 }
931 }
932 _ => {}
933 }
934 }
935
936 fn wrapper(
939 &self,
940 receiver: &[String],
941 method: &str,
942 from: &str,
943 f: &FileFacts,
944 ) -> Option<Client> {
945 if !(HTTP_VERBS.contains(&method) || method == "request") || receiver.is_empty() {
946 return None;
947 }
948 let mut full = receiver.to_vec();
949 full.push(method.to_string());
950 if matches!(f.language, Language::TypeScript | Language::JavaScript) {
951 let Value::Instance(t) = self.b.resolve_path(receiver, from, f, 0)? else {
953 return None;
954 };
955 return self.http_types.contains(&t).then_some(Client::Http);
956 }
957 let Value::Sym(m) = self.b.resolve_path(&full, from, f, 0)? else {
958 return None;
959 };
960 let owner = self.b.parent.get(&m)?;
961 self.owns_http(owner, 0).then_some(Client::Http)
962 }
963
964 fn owns_http(&self, class: &str, depth: u32) -> bool {
965 if depth > 8 {
966 return false;
967 }
968 let own = self.clients.iter().any(|((scope, target), c)| {
969 scope == class && target.starts_with("self.") && matches!(c, Client::Http)
970 });
971 own || self
972 .b
973 .bases
974 .borrow()
975 .get(class)
976 .is_some_and(|bs| bs.iter().any(|b| self.owns_http(b, depth + 1)))
977 }
978
979 fn aws(&self, svc: &str, method: &str, args: &Args, from: &str) -> Option<(String, EdgeKind)> {
982 let (kws, pos, scheme, kind) = aws_spec(svc, method)?;
983 let (expr, at) = kws
984 .iter()
985 .find_map(|k| self.kwarg(args, k, from))
986 .or_else(|| {
987 args.positional
988 .get(pos)
989 .and_then(Option::as_ref)
990 .map(|e| (e.clone(), from.to_string()))
991 })?;
992 if let Some(v) = self.value(&expr, &at, 0) {
993 return Some((scheme_key(scheme, &v), kind));
994 }
995 if let Some((func, param, index, field)) = self.param_origin(&expr, &at, 0) {
996 self.summarise(
997 func,
998 Summary {
999 param,
1000 index,
1001 field,
1002 scheme,
1003 kind,
1004 },
1005 );
1006 }
1007 None
1008 }
1009
1010 fn kwarg(&self, args: &Args, key: &str, from: &str) -> Option<(ValueExpr, String)> {
1013 if let Some((_, v)) = args.keywords.iter().find(|(n, _)| n == key) {
1014 return Some((v.clone(), from.to_string()));
1015 }
1016 args.keywords
1017 .iter()
1018 .filter(|(n, _)| n == "**")
1019 .find_map(|(_, v)| {
1020 let (expr, at) = match v {
1021 ValueExpr::Name(p) => self.locate(p, from, 0)?,
1022 other => (other.clone(), from.to_string()),
1023 };
1024 let found = match &expr {
1025 ValueExpr::Call { kwargs, .. } => kwargs.iter().find(|(k, _)| k == key),
1026 ValueExpr::Record(pairs) => pairs.iter().find(|(k, _)| k == key),
1027 _ => None,
1028 };
1029 found.map(|(_, v)| (v.clone(), at))
1030 })
1031 }
1032
1033 fn summarise(&self, func: String, s: Summary) -> bool {
1034 let mut all = self.summaries.borrow_mut();
1035 let list = all.entry(func).or_default();
1036 if list.contains(&s) {
1037 return false;
1038 }
1039 list.push(s);
1040 true
1041 }
1042
1043 fn param_origin(&self, expr: &ValueExpr, from: &str, depth: u32) -> Option<Origin> {
1046 let ValueExpr::Name(path) = expr else {
1047 return None;
1048 };
1049 if let [recv, attr, rest @ ..] = path.as_slice() {
1050 if recv == "self" || recv == "this" {
1051 let class = self.b.enclosing_class(from)?;
1052 let (f, p, i, mut field) = self.attr_origin(&class, attr, depth)?;
1053 field.extend(rest.iter().cloned());
1054 return Some((f, p, i, field));
1055 }
1056 }
1057 let (head, rest) = path.split_first()?;
1058 let direct = self.b.scope_chain(from).into_iter().find_map(|s| {
1059 let i = positional(self.params.get(s.as_str())?, head)?;
1060 Some((s, head.clone(), i, rest.to_vec()))
1061 });
1062 if direct.is_some() || depth > 6 {
1063 return direct;
1064 }
1065 let ValueExpr::Name(inner) = self.values.get(&(from, head.as_str())).copied()? else {
1067 return None;
1068 };
1069 let mut next = inner.clone();
1070 next.extend(rest.iter().cloned());
1071 self.param_origin(&ValueExpr::Name(next), from, depth + 1)
1072 }
1073
1074 fn attr_origin(&self, class: &str, attr: &str, depth: u32) -> Option<Origin> {
1075 if depth > 6 {
1076 return None;
1077 }
1078 if let Some(ValueExpr::Name(v)) = self.values.get(&(class, attr)).copied() {
1079 if let Some((q, rest)) = v.split_first() {
1080 let init = self.init_of(class, 0)?;
1081 let i = positional(self.params.get(init.as_str())?, q)?;
1082 return Some((init, q.clone(), i, rest.to_vec()));
1083 }
1084 }
1085 let bases = self
1086 .b
1087 .bases
1088 .borrow()
1089 .get(class)
1090 .cloned()
1091 .unwrap_or_default();
1092 bases
1093 .iter()
1094 .find_map(|b| self.attr_origin(b, attr, depth + 1))
1095 }
1096
1097 fn init_of(&self, class: &str, depth: u32) -> Option<String> {
1099 let own = format!("{}/__init__().", class.strip_suffix('#')?);
1100 if self.params.contains_key(own.as_str()) {
1101 return Some(own);
1102 }
1103 if depth > 6 {
1104 return None;
1105 }
1106 let bases = self
1107 .b
1108 .bases
1109 .borrow()
1110 .get(class)
1111 .cloned()
1112 .unwrap_or_default();
1113 bases.iter().find_map(|b| self.init_of(b, depth + 1))
1114 }
1115
1116 fn propagate(&self, facts: &[FileFacts], emit: &mut impl FnMut(&str, String, EdgeKind)) {
1119 for _ in 0..4 {
1120 let known = self.summaries.borrow().clone();
1121 if known.is_empty() {
1122 return;
1123 }
1124 let mut grew = false;
1125 for f in facts {
1126 for c in &f.calls {
1127 let target = match self.b.resolve_path(&c.callee, &c.from, f, 0) {
1128 Some(Value::Sym(id)) if self.b.is_type(&id) => self.init_of(&id, 0),
1129 Some(Value::Sym(id)) => Some(id),
1130 _ => None,
1131 };
1132 let Some(sums) = target.and_then(|t| known.get(&t)) else {
1133 continue;
1134 };
1135 for s in sums {
1136 let Some(arg) = c.args.get(&s.param, s.index) else {
1137 continue;
1138 };
1139 let expr = match (arg, s.field.is_empty()) {
1140 (_, true) => arg.clone(),
1141 (ValueExpr::Name(p), false) => {
1142 ValueExpr::Name(p.iter().chain(&s.field).cloned().collect())
1143 }
1144 _ => continue,
1145 };
1146 if let Some(v) = self.value(&expr, &c.from, 0) {
1147 emit(&c.from, scheme_key(s.scheme, &v), s.kind.clone());
1148 } else if let Some((func, param, index, field)) =
1149 self.param_origin(&expr, &c.from, 0)
1150 {
1151 let next = Summary {
1152 param,
1153 index,
1154 field,
1155 ..s.clone()
1156 };
1157 grew |= self.summarise(func, next);
1158 }
1159 }
1160 }
1161 }
1162 if !grew {
1163 return;
1164 }
1165 }
1166 }
1167
1168 fn pypika_table(&self, v: &ValueExpr, scope: &str) -> Option<String> {
1170 let (expr, at) = match v {
1171 ValueExpr::Name(p) => self.locate(p, scope, 0)?,
1172 other => (other.clone(), scope.to_string()),
1173 };
1174 let ValueExpr::Call { func, args, kwargs } = &expr else {
1175 return None;
1176 };
1177 if func.last().map(String::as_str) != Some("Table") {
1178 return None;
1179 }
1180 let name = kwargs
1181 .iter()
1182 .find(|(k, _)| k == "name")
1183 .map(|(_, v)| v)
1184 .or(args.first())?;
1185 self.value(name, &at, 0)
1186 }
1187
1188 fn model_table(&self, args: &Args, from: &str, f: &FileFacts) -> Option<String> {
1190 let path = match args.positional.first() {
1191 Some(Some(ValueExpr::Name(p))) => p.clone(),
1192 _ => args.calls.iter().find(|(i, _)| *i == 0)?.1.clone(),
1193 };
1194 let Value::Sym(class) = self.b.resolve_path(&path, from, f, 0)? else {
1195 return None;
1196 };
1197 self.b.facts.values().find_map(|ff| {
1198 ff.contracts.iter().find_map(|c| match c {
1199 ContractFact::Table { class: c, name } if *c == class => Some(name.clone()),
1200 _ => None,
1201 })
1202 })
1203 }
1204
1205 fn value(&self, v: &ValueExpr, scope: &str, depth: u32) -> Option<String> {
1207 if depth > 12 {
1208 return None;
1209 }
1210 match v {
1211 ValueExpr::Lit(s) => Some(s.clone()),
1212 ValueExpr::Env(k) => Some(format!("{{env:{k}}}")),
1213 ValueExpr::Template(parts) => Some(
1214 parts
1215 .iter()
1216 .map(|p| match p {
1217 TemplatePart::Lit(s) => s.clone(),
1218 TemplatePart::Expr(Some(path)) => self
1219 .value(&ValueExpr::Name(path.clone()), scope, depth + 1)
1220 .filter(|s| !s.contains("{env:"))
1221 .unwrap_or_else(|| "{}".into()),
1222 TemplatePart::Expr(None) => "{}".into(),
1223 })
1224 .collect(),
1225 ),
1226 ValueExpr::Name(path) => match self.locate(path, scope, depth + 1) {
1227 Some((expr, at)) => self.value(&expr, &at, depth + 1),
1228 None => self.config_value(path, scope).or_else(|| {
1229 let (last, rest) = path.split_last()?;
1231 (!rest.is_empty() && RESOURCE_ATTRS.contains(&last.as_str()))
1232 .then(|| self.value(&ValueExpr::Name(rest.to_vec()), scope, depth + 1))
1233 .flatten()
1234 }),
1235 },
1236 ValueExpr::Join { sep, parts } => {
1237 let vals: Vec<String> = parts
1238 .iter()
1239 .map(|p| {
1240 self.value(p, scope, depth + 1)
1241 .unwrap_or_else(|| "{}".into())
1242 })
1243 .collect();
1244 Some(if sep == "/" {
1245 join_path(&vals)
1246 } else {
1247 vals.concat()
1248 })
1249 }
1250 ValueExpr::Call { func, args, .. } => {
1251 let last = func.last()?.as_str();
1252 if PASS_THROUGH.contains(&last) {
1253 self.value(args.first()?, scope, depth + 1)
1254 } else if *func == ["fmt", "Sprintf"] || *func == ["String", "format"] {
1255 let fmt = self.value(args.first()?, scope, depth + 1)?;
1256 Some(printf_placeholders(&fmt))
1257 } else {
1258 None
1259 }
1260 }
1261 ValueExpr::Record(_) => None,
1262 }
1263 }
1264
1265 fn config_value(&self, path: &[String], scope: &str) -> Option<String> {
1268 let repo = self.b.file_facts_of(scope)?.repo.as_str();
1269 let entries = self.config.get(repo)?;
1270 let segs: Vec<&str> = path.iter().map(String::as_str).collect();
1271 let root = segs.first()?.to_ascii_lowercase();
1272 if segs.len() >= 2
1273 && (root.contains("settings") || root.contains("config") || root == "cfg")
1274 {
1275 let key = segs[1..].join(".");
1276 if let Some((_, v)) = entries.iter().find(|(k, _)| *k == key) {
1277 return Some(v.to_string());
1278 }
1279 }
1280 for k in (2..=segs.len()).rev() {
1281 let suffix = segs[segs.len() - k..].join(".");
1282 let dotted = format!(".{suffix}");
1283 let hits: std::collections::BTreeSet<&str> = entries
1284 .iter()
1285 .filter(|(key, _)| *key == suffix || key.ends_with(&dotted))
1286 .map(|(_, v)| *v)
1287 .collect();
1288 match hits.len() {
1289 0 => continue,
1290 1 => return hits.into_iter().next().map(str::to_string),
1291 _ => return None,
1292 }
1293 }
1294 None
1295 }
1296
1297 fn locate(&self, path: &[String], scope: &str, depth: u32) -> Option<(ValueExpr, String)> {
1300 if depth > 12 {
1301 return None;
1302 }
1303 let f = self.b.file_facts_of(scope)?;
1304 let head = path.first()?;
1305 let mut found = None;
1306 for s in self.b.scope_chain(scope) {
1307 if let Some(v) = self.values.get(&(s.as_str(), head.as_str())) {
1308 found = Some(((*v).clone(), s, 1));
1309 break;
1310 }
1311 }
1312 if found.is_none() {
1313 for k in (1..=path.len()).rev() {
1314 let Some(v) = self.b.resolve_path(&path[..k], scope, f, 0) else {
1315 continue;
1316 };
1317 let owner = match v {
1318 Value::Instance(c) => Some(c),
1319 Value::Sym(id) if self.b.is_type(&id) => Some(id),
1320 Value::Sym(id) => {
1321 let var = self.b.sym(&id)?;
1324 let parent = self.b.parent.get(&id)?.clone();
1325 let instance = self
1326 .b
1327 .types
1328 .get(&(parent.as_str(), var.name.as_str()))
1329 .and_then(|src| self.b.type_of(src, &parent, 0))
1330 .and_then(|v| match v {
1331 Value::Instance(c) => Some(c),
1332 _ => None,
1333 });
1334 if let (Some(c), Some(attr)) = (&instance, path.get(k)) {
1335 if let Some((v, at)) = self.class_value(c, attr, 0) {
1336 found = Some((v, at, k + 1));
1337 break;
1338 }
1339 }
1340 if let Some(v) = self.values.get(&(parent.as_str(), var.name.as_str())) {
1341 found = Some(((*v).clone(), parent, k));
1342 break;
1343 }
1344 instance
1345 }
1346 _ => None,
1347 };
1348 let (Some(class), Some(attr)) = (owner, path.get(k)) else {
1349 continue;
1350 };
1351 if let Some((v, at)) = self.class_value(&class, attr, 0) {
1352 found = Some((v, at, k + 1));
1353 break;
1354 }
1355 }
1356 }
1357 let (mut expr, mut at, used) = found?;
1358 for name in &path[used..] {
1359 let (e, a) = self.field(&expr, &at, name, depth + 1)?;
1360 expr = e;
1361 at = a;
1362 }
1363 Some((expr, at))
1364 }
1365
1366 fn field(
1368 &self,
1369 v: &ValueExpr,
1370 scope: &str,
1371 name: &str,
1372 depth: u32,
1373 ) -> Option<(ValueExpr, String)> {
1374 match v {
1375 ValueExpr::Record(fields) => fields
1376 .iter()
1377 .find(|(k, _)| k == name)
1378 .map(|(_, v)| (v.clone(), scope.to_string())),
1379 ValueExpr::Call { func, kwargs, .. }
1380 if func.last().is_some_and(|f| RECORDS.contains(&f.as_str())) =>
1381 {
1382 kwargs
1383 .iter()
1384 .find(|(k, _)| k == name)
1385 .map(|(_, v)| (v.clone(), scope.to_string()))
1386 }
1387 ValueExpr::Name(p) => {
1388 let mut full = p.clone();
1389 full.push(name.to_string());
1390 self.locate(&full, scope, depth + 1)
1391 }
1392 _ => None,
1393 }
1394 }
1395
1396 fn class_value(&self, class: &str, attr: &str, depth: u32) -> Option<(ValueExpr, String)> {
1398 if depth > 8 {
1399 return None;
1400 }
1401 if let Some(v) = self.values.get(&(class, attr)) {
1402 return Some(((*v).clone(), class.to_string()));
1403 }
1404 let bases = self
1405 .b
1406 .bases
1407 .borrow()
1408 .get(class)
1409 .cloned()
1410 .unwrap_or_default();
1411 bases
1412 .iter()
1413 .find_map(|b| self.class_value(b, attr, depth + 1))
1414 }
1415}
1416
1417fn go_request<'a>(
1421 receiver: &[String],
1422 method: &str,
1423 args: &'a Args,
1424) -> Option<(String, &'a ValueExpr)> {
1425 if receiver != ["http"] {
1426 return None;
1427 }
1428 let at = |i: usize| args.positional.get(i).and_then(Option::as_ref);
1429 let lit = |i: usize| match at(i) {
1430 Some(ValueExpr::Lit(v)) => Some(v.to_uppercase()),
1431 Some(ValueExpr::Name(p)) => p
1432 .last()
1433 .and_then(|m| m.strip_prefix("Method").map(str::to_uppercase)),
1434 _ => None,
1435 };
1436 match method {
1437 "Get" | "Head" => Some((method.to_uppercase(), at(0)?)),
1438 "Post" | "PostForm" => Some(("POST".into(), at(0)?)),
1439 "NewRequest" => Some((lit(0)?, at(1)?)),
1440 "NewRequestWithContext" => Some((lit(1)?, at(2)?)),
1441 _ => None,
1442 }
1443}
1444
1445fn test_path(path: &str) -> bool {
1446 let p = path.to_ascii_lowercase();
1447 let (dirs, file) = p.rsplit_once('/').unwrap_or(("", &p));
1448 let orig = path.rsplit('/').next().unwrap_or(path);
1449 dirs.split('/')
1450 .any(|d| matches!(d, "tests" | "test" | "__tests__" | "e2e" | "testing"))
1451 || file.starts_with("test_")
1452 || file == "conftest.py"
1453 || [
1454 "_test.py", "_test.go", "_test.rs", ".test.ts", ".test.tsx", ".test.js", ".spec.ts",
1455 ".spec.tsx", ".spec.js",
1456 ]
1457 .iter()
1458 .any(|s| file.ends_with(s))
1459 || ["Test.java", "Tests.java", "Test.kt", "Tests.kt"].iter().any(|s| {
1461 orig.strip_suffix(s)
1462 .and_then(|stem| stem.chars().last())
1463 .is_some_and(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
1464 })
1465}
1466
1467fn printf_placeholders(fmt: &str) -> String {
1469 let mut out = String::new();
1470 let mut it = fmt.chars().peekable();
1471 while let Some(c) = it.next() {
1472 if c != '%' {
1473 out.push(c);
1474 continue;
1475 }
1476 match it.peek() {
1477 Some('%') => {
1478 it.next();
1479 out.push('%');
1480 }
1481 Some(_) => {
1482 while it.next().is_some_and(|c| !c.is_ascii_alphabetic()) {}
1483 out.push_str("{}");
1484 }
1485 None => out.push('%'),
1486 }
1487 }
1488 out
1489}
1490
1491fn join_path(parts: &[String]) -> String {
1492 let mut out = String::new();
1493 for (i, p) in parts.iter().enumerate() {
1494 let p = if i == 0 {
1495 p.trim_end_matches('/')
1496 } else {
1497 p.trim_matches('/')
1498 };
1499 if p.is_empty() {
1500 continue;
1501 }
1502 if i > 0 {
1503 out.push('/');
1504 }
1505 out.push_str(p);
1506 }
1507 out
1508}
1509
1510pub fn http_path(url: &str) -> String {
1512 let mut p = url;
1513 for scheme in ["https://", "http://"] {
1514 if let Some(rest) = p.strip_prefix(scheme) {
1515 p = rest.find('/').map_or("/", |i| &rest[i..]);
1516 }
1517 }
1518 while p.starts_with('{') {
1520 p = p.find('}').map_or("", |i| &p[i + 1..]);
1521 }
1522 let p = p.split(['?', '#']).next().unwrap_or_default();
1523 let mut segs: Vec<String> = p
1524 .split('/')
1525 .filter(|s| !s.is_empty())
1526 .map(|s| {
1527 if (s.starts_with('{') && s.ends_with('}')) || s.starts_with(':') {
1528 "{}".to_string()
1529 } else {
1530 s.to_string()
1531 }
1532 })
1533 .collect();
1534 if let Some(last) = segs.last_mut() {
1536 if last.len() > 2 && last.ends_with("{}") {
1537 last.truncate(last.len() - 2);
1538 }
1539 }
1540 format!("/{}", segs.join("/"))
1541}
1542
1543pub fn untier(name: &str) -> String {
1546 const TIERS: &[&str] = &[
1547 "dev",
1548 "develop",
1549 "development",
1550 "stage",
1551 "staging",
1552 "stg",
1553 "prod",
1554 "production",
1555 "prd",
1556 "uat",
1557 "preprod",
1558 "sandbox",
1559 ];
1560 let mut out = String::new();
1561 let mut word = String::new();
1562 let flush = |w: &mut String, out: &mut String| {
1563 if TIERS.contains(&w.to_ascii_lowercase().as_str()) {
1564 out.push_str("{}");
1565 } else {
1566 out.push_str(w);
1567 }
1568 w.clear();
1569 };
1570 for c in name.chars() {
1571 if matches!(c, '-' | '_' | '.' | '/') {
1572 flush(&mut word, &mut out);
1573 out.push(c);
1574 } else {
1575 word.push(c);
1576 }
1577 }
1578 flush(&mut word, &mut out);
1579 out
1580}
1581
1582fn name_tokens(name: &str) -> Vec<String> {
1584 let mut t: Vec<String> = name
1585 .split(['-', '_', '.', ':', '/'])
1586 .filter(|w| !w.is_empty())
1587 .map(str::to_ascii_lowercase)
1588 .collect();
1589 while t.last().is_some_and(|w| {
1590 matches!(
1591 w.as_str(),
1592 "queue" | "topic" | "sns" | "sqs" | "fifo" | "bucket"
1593 )
1594 }) {
1595 t.pop();
1596 }
1597 t
1598}
1599
1600fn queue_name(url: &str) -> String {
1601 if url.starts_with("{env:") {
1602 return url.to_string();
1603 }
1604 url.trim_end_matches('/')
1605 .rsplit('/')
1606 .next()
1607 .unwrap_or(url)
1608 .to_string()
1609}
1610
1611fn arn_name(arn: &str) -> String {
1612 if arn.starts_with("{env:") {
1613 return arn.to_string();
1614 }
1615 arn.rsplit(':').next().unwrap_or(arn).to_string()
1616}
1617
1618fn bucket(b: &str) -> String {
1619 b.trim_start_matches("s3://")
1620 .split('/')
1621 .next()
1622 .unwrap_or(b)
1623 .to_string()
1624}
1625
1626fn sql_tables(sql: &str) -> Vec<(String, bool)> {
1628 let Ok(stmts) = Parser::parse_sql(&GenericDialect {}, sql) else {
1629 return Vec::new();
1630 };
1631 let mut out = Vec::new();
1632 let name = |n: &ObjectName| {
1633 n.to_string()
1634 .rsplit('.')
1635 .next()
1636 .unwrap_or_default()
1637 .to_string()
1638 };
1639 let from = |twj: &[TableWithJoins], out: &mut Vec<(String, bool)>| {
1640 for t in twj {
1641 for f in std::iter::once(&t.relation).chain(t.joins.iter().map(|j| &j.relation)) {
1642 if let TableFactor::Table { name: n, .. } = f {
1643 out.push((name(n), false));
1644 }
1645 }
1646 }
1647 };
1648 for s in stmts {
1649 match s {
1650 Statement::Query(q) => {
1651 if let sqlparser::ast::SetExpr::Select(sel) = q.body.as_ref() {
1652 from(&sel.from, &mut out);
1653 }
1654 }
1655 Statement::Insert(i) => {
1656 let t = i.table.to_string();
1657 out.push((t.rsplit('.').next().unwrap_or(&t).to_string(), true));
1658 }
1659 Statement::Update(u) => {
1660 if let TableFactor::Table { name: n, .. } = &u.table.relation {
1661 out.push((name(n), true));
1662 }
1663 }
1664 Statement::Delete(d) => {
1665 let tables = match &d.from {
1666 sqlparser::ast::FromTable::WithFromKeyword(t)
1667 | sqlparser::ast::FromTable::WithoutKeyword(t) => t,
1668 };
1669 for t in tables {
1670 if let TableFactor::Table { name: n, .. } = &t.relation {
1671 out.push((name(n), true));
1672 }
1673 }
1674 }
1675 _ => {}
1676 }
1677 }
1678 out
1679}
1680
1681pub fn contract_id(key: &str) -> GlobalSymbolId {
1682 GlobalSymbolId {
1683 scheme: "comindly-contract".into(),
1684 package_manager: ".".into(),
1685 package: CONTRACTS.into(),
1686 version: ".".into(),
1687 descriptor: key.into(),
1688 }
1689}
1690
1691fn contract_symbol(key: &str) -> Symbol {
1692 let zero = Position { line: 0, column: 0 };
1693 Symbol {
1694 id: contract_id(key),
1695 name: key.to_string(),
1696 kind: SymbolKind::Contract,
1697 language: Language::Other("contract".into()),
1698 repo: RepoId(CONTRACTS.into()),
1699 file_path: String::new(),
1700 range: Range {
1701 start: zero,
1702 end: zero,
1703 },
1704 signature: None,
1705 docstring: None,
1706 fingerprint: None,
1707 }
1708}
1709
1710#[cfg(test)]
1711mod tests {
1712 use super::*;
1713
1714 #[test]
1715 fn keyword_only_params_have_no_position() {
1716 let names: Vec<String> = ["a", "*", "b"].iter().map(|s| s.to_string()).collect();
1717 assert_eq!(positional(&names, "a"), Some(0));
1718 assert_eq!(positional(&names, "b"), Some(usize::MAX));
1719 }
1720
1721 #[test]
1722 fn tiers_are_not_identity() {
1723 assert_eq!(
1724 untier("courses-dev-api-tasks-queue"),
1725 "courses-{}-api-tasks-queue"
1726 );
1727 assert_eq!(
1728 untier("courses-{}-api-tasks-queue"),
1729 "courses-{}-api-tasks-queue"
1730 );
1731 assert_eq!(untier("acme_prod.events.fifo"), "acme_{}.events.fifo");
1732 assert_eq!(untier("developers-feed"), "developers-feed");
1733 assert_eq!(
1734 name_tokens("courses-{}-x-analysis-events-queue")
1735 .last()
1736 .map(String::as_str),
1737 Some("events")
1738 );
1739 }
1740
1741 #[test]
1742 fn test_paths() {
1743 assert!(test_path("app/tests/e2e/test_x.py"));
1744 assert!(test_path("src/test/java/com/acme/UserTest.java"));
1745 assert!(test_path("web/src/api.spec.ts"));
1746 assert!(test_path("pkg/handler_test.go"));
1747 assert!(!test_path("app/latest_version.py"));
1748 assert!(!test_path("src/contest/routes.py"));
1749 assert!(!test_path("src/main/java/com/acme/Latest.java"));
1750 assert!(!test_path("src/contests.rs"));
1751 assert!(test_path("src/main/kotlin/UserServiceTest.kt"));
1752 }
1753
1754 #[test]
1755 fn framework_path_params_and_printf() {
1756 assert_eq!(http_path("/api/things/:id"), "/api/things/{}");
1757 assert_eq!(http_path("/api/x/{id:[0-9]+}"), "/api/x/{}");
1758 assert_eq!(printf_placeholders("%s/api/users/%d"), "{}/api/users/{}");
1759 assert_eq!(printf_placeholders("100%% %5.2f"), "100% {}");
1760 assert_eq!(
1761 http_path(&printf_placeholders("%s/api/users/%s")),
1762 "/api/users/{}"
1763 );
1764 }
1765
1766 #[test]
1767 fn http_paths_normalize() {
1768 assert_eq!(http_path("/v1/courses/{course_id}"), "/v1/courses/{}");
1769 assert_eq!(
1770 http_path("http://courses-svc/v1/courses/{}?x=1"),
1771 "/v1/courses/{}"
1772 );
1773 assert_eq!(http_path("{}/v1/a//b/"), "/v1/a/b");
1774 assert_eq!(http_path("{env:BASE}/x"), "/x");
1775 assert_eq!(http_path("/api/documents{}"), "/api/documents");
1776 }
1777
1778 #[test]
1779 fn sql_tables_by_statement() {
1780 assert_eq!(
1781 sql_tables("SELECT count(*) FROM courses"),
1782 vec![("courses".into(), false)]
1783 );
1784 assert_eq!(
1785 sql_tables("SELECT * FROM a JOIN b ON a.id = b.id"),
1786 vec![("a".into(), false), ("b".into(), false)]
1787 );
1788 assert_eq!(
1789 sql_tables("INSERT INTO courses (id) VALUES (1)"),
1790 vec![("courses".into(), true)]
1791 );
1792 assert_eq!(
1793 sql_tables("UPDATE x.courses SET a = 1"),
1794 vec![("courses".into(), true)]
1795 );
1796 }
1797}