branch-guards-languages.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. /**
  2. * Branch guards, call sites and decorators for the server languages — Python,
  3. * Java, Kotlin, C#, Go, C — read from source the way the Steps view reads
  4. * them. Every language gets the same four readings the JS rules give: the
  5. * conditions a site runs under (early exits before it included), what it is
  6. * passed, what is called as written, and what is written on its definition.
  7. */
  8. import { describe, it, expect, beforeAll } from 'vitest';
  9. import { initGrammars } from '../src/extraction/grammars';
  10. import { callSiteInSource, decoratorsInSource, guardsInSource, guardLabel, loopsInSource, memberTypesInSource, supportsBranchGuards } from '../src/graph/branch-guards';
  11. import type { Language } from '../src/types';
  12. beforeAll(async () => {
  13. await initGrammars();
  14. });
  15. function lineOf(src: string, needle: string): number {
  16. const i = src.split('\n').findIndex((l) => l.includes(needle));
  17. if (i < 0) throw new Error(`no line contains ${needle}`);
  18. return i + 1;
  19. }
  20. async function labelAt(src: string, needle: string, language: Language): Promise<string> {
  21. const line = lineOf(src, needle);
  22. const column = src.split('\n')[line - 1]!.indexOf(needle);
  23. return guardLabel(await guardsInSource(src, language, line, column));
  24. }
  25. async function siteAt(src: string, needle: string, language: Language) {
  26. const line = lineOf(src, needle);
  27. const column = src.split('\n')[line - 1]!.indexOf(needle);
  28. return callSiteInSource(src, language, line, column);
  29. }
  30. describe('languages with rules', () => {
  31. it('names them', () => {
  32. for (const l of ['python', 'java', 'kotlin', 'csharp', 'go', 'c', 'cpp']) expect(supportsBranchGuards(l)).toBe(true);
  33. expect(supportsBranchGuards('ruby')).toBe(false);
  34. expect(supportsBranchGuards('php')).toBe(false);
  35. });
  36. });
  37. describe('Python', () => {
  38. const src = `
  39. @router.post("/", dependencies=[Depends(auth)])
  40. def create_item(session: SessionDep, item_in: ItemCreate) -> Any:
  41. if not item_in.title:
  42. raise HTTPException(status_code=400, detail="no title")
  43. try:
  44. item = Item.model_validate(item_in, update={"owner_id": 1})
  45. except ValueError as e:
  46. return None
  47. if item.count > 0 and item.ok:
  48. session.add(item)
  49. elif item.count == 0:
  50. session.delete(item)
  51. else:
  52. pass
  53. match item.kind:
  54. case "a":
  55. session.commit()
  56. case _:
  57. pass
  58. x = a if cond else b
  59. for i in items:
  60. if i is None:
  61. continue
  62. session.refresh(i)
  63. return item
  64. `;
  65. it('reads if / elif / match / early exits / the ternary form / the loop guard', async () => {
  66. expect(await labelAt(src, 'raise HTTPException', 'python')).toBe('not item_in.title');
  67. expect(await labelAt(src, 'session.add(item)', 'python')).toBe('item_in.title && item.count > 0 and item.ok');
  68. expect(await labelAt(src, 'session.delete(item)', 'python')).toBe('item_in.title && !(item.count > 0 and item.ok) && item.count == 0');
  69. expect(await labelAt(src, 'session.commit()', 'python')).toBe('item_in.title && item.kind == "a"');
  70. expect(await labelAt(src, 'session.refresh(i)', 'python')).toBe('item_in.title && i is not None');
  71. expect(await labelAt(src, 'return None', 'python')).toBe('item_in.title && on error');
  72. });
  73. it('reads the call as written, with keyword arguments', async () => {
  74. expect(await siteAt(src, 'HTTPException(', 'python')).toMatchObject({ callee: 'HTTPException', args: 'status_code=400, detail="no title"' });
  75. expect(await siteAt(src, 'Item.model_validate', 'python')).toMatchObject({ callee: 'Item.model_validate', args: 'item_in, update={ "owner_id" }' });
  76. });
  77. it('reads the decorators on the definition', async () => {
  78. expect(await decoratorsInSource(src, 'python', lineOf(src, 'def create_item'))).toEqual({
  79. own: ['router.post("/", dependencies=[Depends(auth)])'],
  80. class: [],
  81. });
  82. });
  83. });
  84. describe('Java', () => {
  85. const src = `
  86. @RestController
  87. @RequestMapping("/api")
  88. public class OwnerController {
  89. @PostMapping("/owners/new")
  90. @PreAuthorize("hasRole('ADMIN')")
  91. public String processCreationForm(@Valid Owner owner, BindingResult result) {
  92. if (result.hasErrors()) {
  93. return VIEWS;
  94. }
  95. try {
  96. this.owners.save(owner);
  97. } catch (IllegalStateException e) {
  98. throw new ResponseStatusException(HttpStatus.NOT_FOUND, "x");
  99. }
  100. switch (owner.kind) {
  101. case A: owners.delete(owner); break;
  102. default: return "b";
  103. }
  104. String s = cond ? a() : b();
  105. Owner o = new Owner("x", 3);
  106. return cond && !late ? "redirect:/owners/" + owner.getId() : "x";
  107. }
  108. }
  109. `;
  110. it('reads early exits, try/catch, switch and the ternary', async () => {
  111. expect(await labelAt(src, 'this.owners.save', 'java')).toBe('!result.hasErrors()');
  112. // A negated guard on one call with nested parentheses stays a bare `!`.
  113. const nested = 'class A {\n void f(Owner owner, int ownerId) {\n if (!Objects.equals(owner.getId(), ownerId)) {\n return;\n }\n owners.save(owner);\n }\n}\n';
  114. expect(await labelAt(nested, 'owners.save', 'java')).toBe('Objects.equals(owner.getId(), ownerId)');
  115. expect(await labelAt(src, 'new ResponseStatusException', 'java')).toBe('!result.hasErrors() && on error');
  116. expect(await labelAt(src, 'owners.delete(owner)', 'java')).toBe('!result.hasErrors() && owner.kind == A');
  117. expect(await labelAt(src, 'return "b"', 'java')).toBe('!result.hasErrors() && owner.kind: default');
  118. expect(await labelAt(src, 'a() : b()', 'java')).toBe('!result.hasErrors() && cond');
  119. expect(await labelAt(src, 'owner.getId()', 'java')).toBe('!result.hasErrors() && cond && !late');
  120. });
  121. it('reads the call as written', async () => {
  122. expect(await siteAt(src, 'new Owner(', 'java')).toMatchObject({ callee: 'Owner', args: '"x", 3' });
  123. expect(await siteAt(src, 'this.owners.save', 'java')).toMatchObject({ callee: 'this.owners.save', args: 'owner' });
  124. expect(await siteAt(src, 'new ResponseStatusException', 'java')).toMatchObject({ callee: 'ResponseStatusException', args: 'HttpStatus.NOT_FOUND, "x"' });
  125. });
  126. it('reads the annotations on the method and its class', async () => {
  127. expect(await decoratorsInSource(src, 'java', lineOf(src, 'public String processCreationForm'))).toEqual({
  128. own: ['PostMapping("/owners/new")', 'PreAuthorize("hasRole(\'ADMIN\')")'],
  129. class: ['RestController', 'RequestMapping("/api")'],
  130. });
  131. });
  132. });
  133. describe('Kotlin', () => {
  134. const src = `
  135. @RestController
  136. class OwnerController(val owners: OwnerRepository) {
  137. @PostMapping("/owners/new")
  138. fun processCreationForm(@Valid owner: Owner, result: BindingResult): String {
  139. if (result.hasErrors()) {
  140. return VIEWS
  141. }
  142. try { owners.save(owner) } catch (e: IllegalStateException) { throw NotFound("x") }
  143. when (owner.kind) {
  144. A -> owners.delete(owner)
  145. else -> return "b"
  146. }
  147. val s = if (cond) a() else b()
  148. owner.let { owners.save(it) }
  149. return "redirect:/owners/"
  150. }
  151. }
  152. `;
  153. it('reads early exits, try/catch, when and the if-expression', async () => {
  154. expect(await labelAt(src, 'owners.save(owner)', 'kotlin')).toBe('!result.hasErrors()');
  155. expect(await labelAt(src, 'NotFound("x")', 'kotlin')).toBe('!result.hasErrors() && on error');
  156. expect(await labelAt(src, 'owners.delete(owner)', 'kotlin')).toBe('!result.hasErrors() && owner.kind == A');
  157. expect(await labelAt(src, 'return "b"', 'kotlin')).toBe('!result.hasErrors() && owner.kind: else');
  158. expect(await labelAt(src, 'a() else', 'kotlin')).toBe('!result.hasErrors() && cond');
  159. expect(await labelAt(src, 'b()', 'kotlin')).toBe('!result.hasErrors() && !cond');
  160. // A lambda is inline: the conditions around it are the conditions it runs under.
  161. expect(await labelAt(src, 'owners.save(it)', 'kotlin')).toBe('!result.hasErrors()');
  162. });
  163. it('reads the call as written', async () => {
  164. expect(await siteAt(src, 'owners.delete(owner)', 'kotlin')).toMatchObject({ callee: 'owners.delete', args: 'owner' });
  165. // A trailing lambda is `{ … }`, as Swift's closure is — not its body.
  166. const lambda = 'class A(val prefs: DataStore<P>) {\n suspend fun set(b: Boolean) {\n prefs.updateData { it.copy { bookmarked = b } }\n }\n}\n';
  167. expect(await siteAt(lambda, 'prefs.updateData', 'kotlin')).toMatchObject({ callee: 'prefs.updateData', args: '{ … }' });
  168. });
  169. it('reads the annotations', async () => {
  170. expect(await decoratorsInSource(src, 'kotlin', lineOf(src, 'fun processCreationForm'))).toEqual({
  171. own: ['PostMapping("/owners/new")'],
  172. class: ['RestController'],
  173. });
  174. });
  175. });
  176. describe('C#', () => {
  177. const src = `
  178. [ApiController]
  179. public class TodoController : ControllerBase {
  180. [HttpPost("items")]
  181. [Authorize(Roles = "Admin")]
  182. public async Task<IActionResult> Create([FromBody] Item item) {
  183. if (item == null) return BadRequest();
  184. try { await _context.Items.AddAsync(item); } catch (DbUpdateException e) { return Conflict(); }
  185. switch (item.Kind) { case 1: _bus.Publish(item); break; default: break; }
  186. var x = cond ? Ok(item) : NotFound();
  187. return item.Ok && !late ? Created("x", item) : StatusCode(500);
  188. }
  189. }
  190. `;
  191. it('reads early exits, try/catch, switch and the conditional', async () => {
  192. expect(await labelAt(src, '_context.Items.AddAsync', 'csharp')).toBe('item != null');
  193. expect(await labelAt(src, 'Conflict()', 'csharp')).toBe('item != null && on error');
  194. expect(await labelAt(src, '_bus.Publish', 'csharp')).toBe('item != null && item.Kind == 1');
  195. expect(await labelAt(src, 'Ok(item)', 'csharp')).toBe('item != null && cond');
  196. expect(await labelAt(src, 'NotFound()', 'csharp')).toBe('item != null && !cond');
  197. expect(await labelAt(src, 'Created("x"', 'csharp')).toBe('item != null && item.Ok && !late');
  198. expect(await labelAt(src, 'StatusCode(500)', 'csharp')).toBe('item != null && !(item.Ok && !late)');
  199. });
  200. it('reads the call as written', async () => {
  201. expect(await siteAt(src, '_context.Items.AddAsync', 'csharp')).toMatchObject({ callee: '_context.Items.AddAsync', args: 'item' });
  202. expect(await siteAt(src, 'Created("x"', 'csharp')).toMatchObject({ callee: 'Created', args: '"x", item' });
  203. });
  204. it('reads the attributes on the action and its controller', async () => {
  205. expect(await decoratorsInSource(src, 'csharp', lineOf(src, 'public async Task<IActionResult> Create'))).toEqual({
  206. own: ['HttpPost("items")', 'Authorize(Roles = "Admin")'],
  207. class: ['ApiController'],
  208. });
  209. });
  210. });
  211. describe('Go', () => {
  212. const src = `
  213. package main
  214. func createUser(c *gin.Context) {
  215. if err := c.BindJSON(&u); err != nil {
  216. c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
  217. return
  218. }
  219. if u.Name == "" && !ok {
  220. c.AbortWithStatus(404)
  221. } else if u.Age > 3 {
  222. db.Create(&u)
  223. } else {
  224. db.Save(&u)
  225. }
  226. switch u.Kind {
  227. case "a":
  228. q.Publish("x", u)
  229. default:
  230. return
  231. }
  232. go worker(u)
  233. c.JSON(http.StatusCreated, u)
  234. }
  235. `;
  236. it('reads the idiomatic error guard flipped, else-if chains and the switch', async () => {
  237. expect(await labelAt(src, 'c.JSON(http.StatusBadRequest', 'go')).toBe('err != nil');
  238. expect(await labelAt(src, 'c.AbortWithStatus', 'go')).toBe('err == nil && u.Name == "" && !ok');
  239. expect(await labelAt(src, 'db.Create', 'go')).toBe('err == nil && !(u.Name == "" && !ok) && u.Age > 3');
  240. expect(await labelAt(src, 'db.Save', 'go')).toBe('err == nil && !(u.Name == "" && !ok) && !(u.Age > 3)');
  241. expect(await labelAt(src, 'q.Publish', 'go')).toBe('err == nil && u.Kind == "a"');
  242. expect(await labelAt(src, 'worker(u)', 'go')).toBe('err == nil');
  243. });
  244. it('reads the call as written, a composite literal as its type', async () => {
  245. expect(await siteAt(src, 'c.JSON(http.StatusBadRequest', 'go')).toMatchObject({ callee: 'c.JSON', args: 'http.StatusBadRequest, gin.H{…}' });
  246. });
  247. });
  248. describe('C', () => {
  249. const src = `
  250. int main(int argc, char **argv) {
  251. FILE *f = fopen(argv[1], "r");
  252. if (!f) { perror("open"); return 1; }
  253. if (argc > 2 && flag) fprintf(stderr, "x %d", argc);
  254. else exit(2);
  255. switch (argc) { case 1: fclose(f); break; default: break; }
  256. int x = argc ? read(fd, buf, 10) : 0;
  257. return 0;
  258. }
  259. `;
  260. it('reads the null-check guard, if/else, switch and the ternary', async () => {
  261. expect(await labelAt(src, 'perror(', 'c')).toBe('!f');
  262. expect(await labelAt(src, 'fprintf(', 'c')).toBe('f && argc > 2 && flag');
  263. expect(await labelAt(src, 'exit(2)', 'c')).toBe('f && !(argc > 2 && flag)');
  264. expect(await labelAt(src, 'fclose(f)', 'c')).toBe('f && argc == 1');
  265. expect(await labelAt(src, 'read(fd', 'c')).toBe('f && argc');
  266. });
  267. it('reads the call as written', async () => {
  268. expect(await siteAt(src, 'fprintf(', 'c')).toMatchObject({ callee: 'fprintf', args: 'stderr, "x %d", argc' });
  269. });
  270. });
  271. describe('member types', () => {
  272. it('TypeScript: constructor parameter properties and typed fields', async () => {
  273. const src = `
  274. @Injectable()
  275. export class CatsService {
  276. private readonly log: Logger = new Logger()
  277. constructor(
  278. @InjectRepository(Cat) private readonly catsRepository: Repository<Cat>,
  279. private readonly mailer: MailerService,
  280. plain: string
  281. ) {}
  282. async create(dto) {
  283. return this.catsRepository.save(dto)
  284. }
  285. }
  286. `;
  287. const types = await memberTypesInSource(src, 'typescript', lineOf(src, 'async create'));
  288. expect(Object.fromEntries(types)).toEqual({ log: 'Logger', catsRepository: 'Repository<Cat>', mailer: 'MailerService' });
  289. });
  290. it('Java: fields and constructor parameters', async () => {
  291. const src = `
  292. public class OwnerController {
  293. private final OwnerRepository owners;
  294. private VisitService visits;
  295. public OwnerController(OwnerRepository owners, Clock clock) { this.owners = owners; }
  296. public String create(Owner owner) { return owners.save(owner); }
  297. }
  298. `;
  299. const types = await memberTypesInSource(src, 'java', lineOf(src, 'public String create'));
  300. expect(Object.fromEntries(types)).toEqual({ owners: 'OwnerRepository', visits: 'VisitService', clock: 'Clock' });
  301. });
  302. it('Kotlin: the primary constructor and properties', async () => {
  303. const src = `
  304. class OwnerController(val owners: OwnerRepository, private val visits: VisitService, plain: String) {
  305. val clock: Clock = Clock.systemUTC()
  306. fun create(owner: Owner): String = owners.save(owner)
  307. }
  308. `;
  309. const types = await memberTypesInSource(src, 'kotlin', lineOf(src, 'fun create'));
  310. expect(Object.fromEntries(types)).toEqual({ owners: 'OwnerRepository', visits: 'VisitService', clock: 'Clock' });
  311. });
  312. it('C#: fields, properties and constructor parameters', async () => {
  313. const src = `
  314. public class OrderService : IOrderService {
  315. private readonly IRepository<Order> _orderRepository;
  316. public IEmailSender Mailer { get; }
  317. public OrderService(IRepository<Order> orderRepository, IUriComposer uriComposer) { _orderRepository = orderRepository; }
  318. public async Task Create(Order o) { await _orderRepository.AddAsync(o); }
  319. }
  320. `;
  321. const types = await memberTypesInSource(src, 'csharp', lineOf(src, 'public async Task Create'));
  322. expect(Object.fromEntries(types)).toEqual({ _orderRepository: 'IRepository<Order>', Mailer: 'IEmailSender', orderRepository: 'IRepository<Order>', uriComposer: 'IUriComposer' });
  323. });
  324. });
  325. describe('loops a site is written inside', () => {
  326. /** The loop headers at the site, outermost first, as `<kind> <text>`. */
  327. async function loopsAt(src: string, needle: string, language: Language) {
  328. const line = lineOf(src, needle);
  329. const column = src.split('\n')[line - 1]!.indexOf(needle);
  330. return (await loopsInSource(src, language, line, column)).map((l) => `${l.kind} ${l.text}`);
  331. }
  332. it('reads a JS for-of and a while', async () => {
  333. const src = `
  334. function run(items) {
  335. for (const item of items) {
  336. save(item)
  337. }
  338. while (queue.length > 0) {
  339. drain()
  340. }
  341. }`;
  342. expect(await loopsAt(src, 'save(item)', 'javascript')).toEqual(['each item of items']);
  343. expect(await loopsAt(src, 'drain()', 'javascript')).toEqual(['while queue.length > 0']);
  344. });
  345. it('reads nested loops outermost first', async () => {
  346. const src = `
  347. function run(rows) {
  348. for (const row of rows) {
  349. for (const cell of row) {
  350. draw(cell)
  351. }
  352. }
  353. }`;
  354. expect(await loopsAt(src, 'draw(cell)', 'javascript')).toEqual(['each row of rows', 'each cell of row']);
  355. });
  356. it('reads nothing for a site outside every loop', async () => {
  357. const src = `
  358. function run(items) {
  359. begin()
  360. for (const item of items) { save(item) }
  361. }`;
  362. expect(await loopsAt(src, 'begin()', 'javascript')).toEqual([]);
  363. });
  364. it('reads a Python for and a while', async () => {
  365. const src = `
  366. def run(items):
  367. for item in items:
  368. save(item)
  369. while pending:
  370. drain()
  371. `;
  372. expect(await loopsAt(src, 'save(item)', 'python')).toEqual(['each item in items']);
  373. expect(await loopsAt(src, 'drain()', 'python')).toEqual(['while pending']);
  374. });
  375. it('reads a Java enhanced for', async () => {
  376. const src = `
  377. class A {
  378. void run(List<Item> items) {
  379. for (Item item : items) {
  380. save(item);
  381. }
  382. }
  383. }`;
  384. expect(await loopsAt(src, 'save(item)', 'java')).toEqual(['each Item item : items']);
  385. });
  386. it('reads a Go range loop', async () => {
  387. const src = `
  388. func run(items []Item) {
  389. for _, item := range items {
  390. save(item)
  391. }
  392. }`;
  393. expect(await loopsAt(src, 'save(item)', 'go')).toEqual(['each _, item := range items']);
  394. });
  395. it('reads a C# foreach', async () => {
  396. const src = `
  397. class A {
  398. void Run(List<Item> items) {
  399. foreach (var item in items) {
  400. Save(item);
  401. }
  402. }
  403. }`;
  404. // The binding word is noise in a header a person reads: `var` goes.
  405. expect(await loopsAt(src, 'Save(item)', 'csharp')).toEqual(['each item in items']);
  406. });
  407. it('reads a Swift for-in', async () => {
  408. const src = `
  409. func run(items: [Item]) {
  410. for item in items {
  411. save(item)
  412. }
  413. }`;
  414. expect(await loopsAt(src, 'save(item)', 'swift')).toEqual(['each item in items']);
  415. });
  416. it('reads a Kotlin for', async () => {
  417. const src = `
  418. fun run(items: List<Item>) {
  419. for (item in items) {
  420. save(item)
  421. }
  422. }`;
  423. expect(await loopsAt(src, 'save(item)', 'kotlin')).toEqual(['each item in items']);
  424. });
  425. it('reads nothing for a language without rules', async () => {
  426. expect(await loopsInSource('def f\n xs.each { save }\nend\n', 'ruby', 2, 2)).toEqual([]);
  427. });
  428. });