Skip to content

Commit 8d5f4c2

Browse files
committed
feat(cli): add analyze command for vuetify usage detection
Add new analyze command to scan project files and detect Vuetify usage patterns. Includes necessary dependencies and shared utilities for code analysis.
1 parent 28a7efd commit 8d5f4c2

File tree

8 files changed

+283
-9
lines changed

8 files changed

+283
-9
lines changed

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"devDependencies": {
2727
"@bomb.sh/tab": "catalog:",
2828
"@clack/prompts": "catalog:",
29+
"@typescript-eslint/parser": "catalog:",
2930
"@vuetify/cli-shared": "workspace:*",
3031
"citty": "catalog:",
3132
"giget": "catalog:",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { log } from '@clack/prompts'
2+
import { analyzeProject } from '@vuetify/cli-shared'
3+
import { defineCommand } from 'citty'
4+
import { resolve } from 'pathe'
5+
6+
export const analyze = defineCommand({
7+
meta: {
8+
name: 'analyze',
9+
description: 'Analyze Vuetify usage in the project',
10+
},
11+
args: {
12+
dir: {
13+
type: 'positional',
14+
description: 'Directory to scan',
15+
default: '.',
16+
},
17+
},
18+
run: async ({ args }) => {
19+
log.warn('This command is experimental and may change in the future.')
20+
const cwd = resolve(process.cwd(), args.dir)
21+
const features = await analyzeProject(cwd)
22+
console.log(JSON.stringify(features, null, 2))
23+
},
24+
})
25+
26+
export default analyze

packages/cli/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { checkForUpdate } from '@vuetify/cli-shared/utils'
66

77
import { defineCommand, runMain, showUsage } from 'citty'
88
import { version } from '../package.json'
9+
import { analyze } from './commands/analyze'
910
import { docs } from './commands/docs'
1011
import { init } from './commands/init'
1112
import { update } from './commands/update'
@@ -23,6 +24,7 @@ export const main = defineCommand({
2324
update,
2425
docs,
2526
upgrade,
27+
analyze,
2628
},
2729
run: async ({ args, cmd }) => {
2830
if (args._[0] === 'complete') {

packages/shared/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,8 +39,11 @@
3939
}
4040
},
4141
"dependencies": {
42+
"@typescript-eslint/parser": "catalog:",
4243
"magicast": "catalog:",
4344
"semver": "catalog:",
44-
"validate-npm-package-name": "catalog:"
45+
"tinyglobby": "catalog:",
46+
"validate-npm-package-name": "catalog:",
47+
"vue-eslint-parser": "catalog:"
4548
}
4649
}
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { readFile } from 'node:fs/promises'
2+
import { createRequire } from 'node:module'
3+
import { glob } from 'tinyglobby'
4+
import { parse } from 'vue-eslint-parser'
5+
6+
const require = createRequire(import.meta.url)
7+
8+
function walk (node: any, callback: (node: any, parent: any) => void, parent?: any) {
9+
if (!node || typeof node !== 'object') {
10+
return
11+
}
12+
13+
callback(node, parent)
14+
15+
for (const key in node) {
16+
if (key === 'parent' || key === 'loc' || key === 'range' || key === 'tokens' || key === 'comments') {
17+
continue
18+
}
19+
const child = node[key]
20+
if (Array.isArray(child)) {
21+
for (const item of child) {
22+
walk(item, callback, node)
23+
}
24+
} else {
25+
walk(child, callback, node)
26+
}
27+
}
28+
}
29+
30+
export function analyzeCode (code: string, targetPackage = '@vuetify/v0') {
31+
const ast = parse(code, {
32+
sourceType: 'module',
33+
ecmaVersion: 2022,
34+
parser: require.resolve('@typescript-eslint/parser'),
35+
})
36+
37+
const found = new Set<string>()
38+
const importedFromVuetify = new Set<string>()
39+
40+
if (ast.body) {
41+
walk(ast, (node, parent) => {
42+
// Static imports: import { X } from 'pkg'
43+
if (node.type === 'ImportDeclaration' && typeof node.source.value === 'string' && (node.source.value === targetPackage || node.source.value.startsWith(`${targetPackage}/`))) {
44+
for (const spec of node.specifiers) {
45+
if (spec.type === 'ImportSpecifier' && 'name' in spec.imported) {
46+
found.add(spec.imported.name)
47+
importedFromVuetify.add(spec.local.name)
48+
} else if (spec.type === 'ImportDefaultSpecifier') {
49+
found.add('default')
50+
importedFromVuetify.add(spec.local.name)
51+
}
52+
}
53+
}
54+
55+
// Dynamic imports: import('pkg').then(...) or import('pkg')['prop']
56+
// Node structure for import('pkg'): { type: 'ImportExpression', source: { value: 'pkg' } }
57+
if (node.type === 'ImportExpression' && node.source.value === targetPackage // Case 1: import('pkg')['Prop'] or import('pkg').Prop
58+
&& parent?.type === 'MemberExpression' && parent.object === node) {
59+
if (parent.property.type === 'Identifier' && !parent.computed) {
60+
// .Prop
61+
found.add(parent.property.name)
62+
} else if (parent.property.type === 'Literal') {
63+
// ['Prop']
64+
found.add(parent.property.value)
65+
}
66+
}
67+
// Case 2: (await import('pkg')).Prop
68+
// AwaitExpression -> MemberExpression
69+
// parent is AwaitExpression
70+
// parent.parent is MemberExpression
71+
// We can't easily access parent.parent with simple walk unless we pass it down or track path.
72+
// But our walk function passes `parent`.
73+
// So we need to check if we are inside an AwaitExpression, and that AwaitExpression is part of MemberExpression.
74+
// This direction (upwards) is hard if we only have one level of parent.
75+
// Instead, let's catch MemberExpression and check if object is AwaitExpression -> ImportExpression
76+
77+
// Handle (await import('pkg')).Prop
78+
if (node.type === 'MemberExpression' // Check if object is AwaitExpression
79+
&& node.object.type === 'AwaitExpression' && node.object.argument.type === 'ImportExpression') {
80+
const source = node.object.argument.source.value
81+
if (source === targetPackage) {
82+
if (node.property.type === 'Identifier' && !node.computed) {
83+
found.add(node.property.name)
84+
} else if (node.property.type === 'Literal') {
85+
found.add(node.property.value)
86+
}
87+
}
88+
}
89+
})
90+
}
91+
92+
return Array.from(found)
93+
}
94+
95+
export async function analyzeProject (cwd: string = process.cwd(), targetPackage = '@vuetify/v0') {
96+
const files = await glob(['**/*.{vue,ts,js,tsx,jsx}'], {
97+
cwd,
98+
ignore: ['**/node_modules/**', '**/dist/**', '**/.git/**'],
99+
absolute: true,
100+
})
101+
102+
const features = new Set<string>()
103+
104+
for (const file of files) {
105+
try {
106+
const code = await readFile(file, 'utf8')
107+
const fileFeatures = analyzeCode(code, targetPackage)
108+
for (const feature of fileFeatures) {
109+
features.add(feature)
110+
}
111+
} catch {
112+
// console.warn(`Failed to analyze ${file}:`, error)
113+
}
114+
}
115+
116+
return Array.from(features).toSorted()
117+
}

packages/shared/src/functions/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
export * from './analyze'
12
export * from './create'
23
export * from './docs'
34
export * from './eslint'

0 commit comments

Comments
 (0)