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