Skip to content

Commit 73ab533

Browse files
committed
feat: add helpers to extract class methods, validator direct usage and method arguments
1 parent a0ff8a1 commit 73ab533

8 files changed

+694
-2
lines changed

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"dependencies": {
6060
"@adonisjs/env": "^6.2.0",
6161
"@antfu/install-pkg": "^1.1.0",
62+
"@ast-grep/napi": "^0.39.3",
6263
"@poppinss/cliui": "^6.4.4",
6364
"@poppinss/hooks": "^7.2.6",
6465
"@poppinss/utils": "^7.0.0-next.3",
@@ -69,6 +70,7 @@
6970
"get-port": "^7.1.0",
7071
"junk": "^4.0.1",
7172
"open": "^10.2.0",
73+
"parse-imports": "^2.2.1",
7274
"picomatch": "^4.0.3",
7375
"pretty-hrtime": "^1.0.3",
7476
"tmp-cache": "^1.1.0",

src/types/common.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,26 @@
88
*/
99

1010
import type { Logger } from '@poppinss/cliui'
11-
1211
import {
1312
type BundlerHooks,
1413
type DevServerHooks,
1514
type TestRunnerHooks,
1615
type WatcherHooks,
1716
} from './hooks.ts'
1817

18+
/**
19+
* Represents an import statement
20+
*/
21+
export type Import = {
22+
specifier: string
23+
isConstant: boolean
24+
clause: {
25+
type: 'namespace' | 'default' | 'named'
26+
value: string
27+
alias?: string
28+
}
29+
}
30+
1931
/**
2032
* File inspected by the filesystem based upon the provided globs
2133
* and the current file path

src/utils.ts

Lines changed: 181 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import getRandomPort from 'get-port'
1616
import type tsStatic from 'typescript'
1717
import { fileURLToPath } from 'node:url'
1818
import { execaNode, execa } from 'execa'
19+
import { parseImports } from 'parse-imports'
20+
import { type SgNode } from '@ast-grep/napi'
1921
import { importDefault } from '@poppinss/utils'
2022
import { copyFile, mkdir } from 'node:fs/promises'
2123
import { EnvLoader, EnvParser } from '@adonisjs/env'
@@ -24,7 +26,7 @@ import { type UnWrapLazyImport } from '@poppinss/utils/types'
2426
import { basename, dirname, isAbsolute, join, relative } from 'node:path'
2527

2628
import debug from './debug.ts'
27-
import type { RunScriptOptions } from './types/common.ts'
29+
import type { Import, RunScriptOptions } from './types/common.ts'
2830
import {
2931
type WatcherHooks,
3032
type BundlerHooks,
@@ -318,3 +320,181 @@ export function throttle<Args extends any[]>(
318320

319321
return throttled
320322
}
323+
324+
/**
325+
* Finds an import reference inside a code snippet
326+
*/
327+
export async function findImport(code: string, importReference: string): Promise<Import | null> {
328+
const importIdentifier = importReference.split('.')[0]
329+
330+
for (const $import of await parseImports(code, {})) {
331+
/**
332+
* An import without any clause
333+
*/
334+
if (!$import.importClause) {
335+
continue
336+
}
337+
338+
/**
339+
* An import without any clause
340+
*/
341+
if (!$import.moduleSpecifier.value) {
342+
continue
343+
}
344+
345+
/**
346+
* Import identifier matches a default import
347+
*/
348+
if ($import.importClause.default === importIdentifier) {
349+
return {
350+
specifier: $import.moduleSpecifier.value,
351+
isConstant: $import.moduleSpecifier.isConstant,
352+
clause: {
353+
type: 'default',
354+
value: importIdentifier,
355+
},
356+
} satisfies Import
357+
}
358+
359+
/**
360+
* Import identifier matches a namespace import
361+
*/
362+
if ($import.importClause.namespace === importIdentifier) {
363+
return {
364+
specifier: $import.moduleSpecifier.value,
365+
isConstant: $import.moduleSpecifier.isConstant,
366+
clause: {
367+
type: 'namespace',
368+
value: importIdentifier,
369+
},
370+
} satisfies Import
371+
}
372+
373+
const namedImport = $import.importClause.named.find(({ binding }) => {
374+
return binding === importIdentifier
375+
})
376+
377+
/**
378+
* Import identifier matches a named import
379+
*/
380+
if (namedImport) {
381+
return {
382+
specifier: $import.moduleSpecifier.value,
383+
isConstant: $import.moduleSpecifier.isConstant,
384+
clause: {
385+
type: 'named',
386+
value: namedImport.specifier,
387+
...(namedImport.binding !== namedImport.specifier
388+
? {
389+
alias: namedImport.binding,
390+
}
391+
: {}),
392+
},
393+
} satisfies Import
394+
}
395+
}
396+
397+
return null
398+
}
399+
400+
/**
401+
* Returns an array of SgNodes for class methods. The class name
402+
* could be fetched using the `KCLASS` identifier.
403+
*/
404+
export function inspectClassMethods(node: SgNode): SgNode[] {
405+
return node.findAll({
406+
rule: {
407+
kind: 'method_definition',
408+
inside: {
409+
pattern: {
410+
selector: 'class_declaration',
411+
context: 'class $KCLASS {$$$}',
412+
},
413+
stopBy: 'end',
414+
},
415+
},
416+
})
417+
}
418+
419+
/**
420+
* Converts an array of SgNode to plain text by removing whitespaces,
421+
* identation and comments in between. Tested with the following
422+
* children nodes only.
423+
*
424+
* - MemberExpression
425+
* - Identifer
426+
*/
427+
export function nodeToPlainText(node: SgNode) {
428+
let out: string[] = []
429+
function toText(one: SgNode) {
430+
const children = one.children()
431+
if (!children.length) {
432+
out.push(one.text())
433+
} else {
434+
children.forEach((child) => toText(child))
435+
}
436+
}
437+
438+
toText(node)
439+
return out.join('')
440+
}
441+
442+
/**
443+
* Inspects arguments for one or more method calls. If you want to
444+
* scope the search within a specific context, then make sure to
445+
* first narrow down the AST and pass a specific SgNode.
446+
*
447+
* For example: In case of validators, we will first find the Controller
448+
* method for which we want the validation method calls.
449+
*/
450+
export function inspectMethodArguments(node: SgNode, methodCalls: string[]): SgNode[] {
451+
const matchingExpressions = node.findAll({
452+
rule: {
453+
any: methodCalls.map((methodCall) => {
454+
return {
455+
pattern: {
456+
context: `${methodCall}($$$ARGUMENTS)`,
457+
selector: 'call_expression',
458+
},
459+
}
460+
}),
461+
},
462+
})
463+
464+
return matchingExpressions.flatMap((matchingExpression) => {
465+
return matchingExpression.findAll({ rule: { kind: 'arguments' } })
466+
})
467+
}
468+
469+
/**
470+
* Inspect the validator direct usage code snippets. A member expression
471+
* calling the ".validate" method is considered as direct usage of
472+
* the validator.
473+
*/
474+
export function searchValidatorDirectUsage(node: SgNode): SgNode[] {
475+
const matchingExpressions = node.findAll({
476+
rule: {
477+
pattern: {
478+
context: '$$$VALIDATOR.validate($$$)',
479+
selector: 'call_expression',
480+
},
481+
},
482+
})
483+
484+
return matchingExpressions.flatMap((expression) => {
485+
/**
486+
* Since we capture all "$$$.validate" calls, the first node
487+
* within the call expression will be a member expression
488+
* and its property identifier will be "validate".
489+
*
490+
* What we need is the text of this member expression without the
491+
* comments
492+
*/
493+
const firstMemberExpression = expression.getMultipleMatches('VALIDATOR')
494+
return firstMemberExpression.flatMap((exp) => {
495+
return exp.find({
496+
rule: { any: [{ kind: 'identifier' }, { kind: 'member_expression' }] },
497+
})!
498+
})
499+
})
500+
}

0 commit comments

Comments
 (0)