|
12 | 12 |
|
13 | 13 | /* eslint-disable no-restricted-syntax, import/no-extraneous-dependencies */ |
14 | 14 |
|
15 | | -const fs = require('fs'); |
16 | | -const path = require('path'); |
17 | | - |
18 | 15 | const Parser = require('tree-sitter'); |
19 | 16 | const JavaScript = require('tree-sitter-javascript'); |
20 | 17 | const JSDoc = require('tree-sitter-jsdoc'); |
21 | | -const Vue = require('tree-sitter-vue'); |
22 | | - |
23 | | -async function* find(dir, predicate = () => true) { |
24 | | - const files = await fs.promises.readdir(dir); |
25 | | - |
26 | | - for await (const file of files) { |
27 | | - const fpath = path.join(dir, file); |
28 | | - const fstat = await fs.promises.stat(fpath); |
29 | | - |
30 | | - if (fstat.isDirectory()) { |
31 | | - yield* find(fpath, predicate); |
32 | | - } else if (predicate(fpath)) { |
33 | | - yield fpath; |
34 | | - } |
35 | | - } |
36 | | -} |
37 | | - |
38 | | -async function* findVueFiles(dir) { |
39 | | - const isVueFile = fpath => path.extname(fpath) === '.vue'; |
40 | | - yield* find(dir, isVueFile); |
41 | | -} |
42 | | - |
43 | | -function uniqueCaptures(query, tree) { |
44 | | - const capturesArray = query.captures(tree.rootNode); |
45 | | - return capturesArray.reduce((obj, capture) => ({ |
46 | | - ...obj, |
47 | | - [capture.name]: capture.node, |
48 | | - }), {}); |
49 | | -} |
50 | | - |
51 | | -const line = ( |
52 | | - text, |
53 | | - range = { |
54 | | - start: { // FIXME: use real ranges from parser in the future |
55 | | - line: 0, |
56 | | - character: 0, |
57 | | - }, |
58 | | - end: { |
59 | | - line: 0, |
60 | | - character: 0, |
61 | | - }, |
62 | | - }, |
63 | | -) => ({ text, range }); |
64 | | - |
65 | | -function createDocComment(descriptionNode = { text: '' }, params = []) { |
66 | | - const lines = descriptionNode.text.split('\n').map((txt, i) => line((i === 0 ? ( |
67 | | - txt |
68 | | - ) : ( |
69 | | - // this seems like a tree-sitter-jsdoc bug with handling |
70 | | - // multi-line descriptions |
71 | | - txt.replace(/^\s*\*/, '') |
72 | | - )))); |
73 | | - |
74 | | - // generate automated parameter description content that just shows the name |
75 | | - // of each prop and its type |
76 | | - const hasParamContent = lines.some(l => /^\s*- Parameter/.test(l.text)); |
77 | | - if (params.length && !hasParamContent) { |
78 | | - lines.push(line('')); |
79 | | - lines.push(line('- Parameters:')); |
80 | | - params.forEach((param) => { |
81 | | - lines.push(line(` - ${param.name}: \`${param.type}\``)); |
82 | | - }); |
83 | | - } |
84 | | - |
85 | | - return { lines }; |
86 | | -} |
87 | | - |
88 | | -const Token = { |
89 | | - identifier: spelling => ({ kind: 'identifier', spelling }), |
90 | | - string: spelling => ({ kind: 'string', spelling }), |
91 | | - text: spelling => ({ kind: 'text', spelling }), |
92 | | - typeIdentifier: spelling => ({ kind: 'typeIdentifier', spelling }), |
93 | | -}; |
94 | | - |
95 | | -function createDeclaration(componentName, slotNames = []) { |
96 | | - if (!slotNames.length) { |
97 | | - return [ |
98 | | - Token.text('<'), |
99 | | - Token.typeIdentifier(componentName), |
100 | | - Token.text(' />'), |
101 | | - ]; |
102 | | - } |
103 | | - const isDefault = name => name === 'default'; |
104 | | - return [ |
105 | | - Token.text('<'), |
106 | | - Token.typeIdentifier(componentName), |
107 | | - Token.text('>\n'), |
108 | | - ...slotNames.flatMap(name => (isDefault(name) ? ([ |
109 | | - Token.text(' <slot />\n'), |
110 | | - ]) : ([ |
111 | | - Token.text(' <slot name='), |
112 | | - Token.string(`"${name}"`), |
113 | | - Token.text(' />\n'), |
114 | | - ]))), |
115 | | - Token.text('</'), |
116 | | - Token.typeIdentifier(componentName), |
117 | | - Token.text('>'), |
118 | | - ]; |
119 | | -} |
120 | 18 |
|
121 | 19 | (async () => { |
122 | | - const vueParser = new Parser(); |
123 | | - vueParser.setLanguage(Vue); |
124 | | - const scriptTextQuery = new Parser.Query(Vue, |
125 | | - `(script_element |
126 | | - (raw_text) @script)`); |
127 | | - |
128 | 20 | const jsParser = new Parser(); |
129 | 21 | jsParser.setLanguage(JavaScript); |
130 | | - const exportNameQuery = new Parser.Query(JavaScript, |
131 | | - `( |
132 | | - (comment)? @comment (#match? @comment "^/[*]{2}") |
133 | | - . |
134 | | - (export_statement |
135 | | - (object |
136 | | - (pair |
137 | | - (property_identifier) @key (#eq? @key "name") |
138 | | - . |
139 | | - (string (string_fragment) @component)))))`); |
140 | | - const exportPropsQuery = new Parser.Query(JavaScript, |
141 | | - `(export_statement |
142 | | - (object |
143 | | - (pair |
144 | | - (property_identifier) @key (#eq? @key "props") |
145 | | - . |
146 | | - (object |
147 | | - (pair |
148 | | - (property_identifier) @prop.name |
149 | | - . |
150 | | - (object |
151 | | - (pair |
152 | | - (property_identifier) @key2 (#eq? @key2 "type") |
153 | | - . |
154 | | - (_) @prop.type)))))))`); |
155 | 22 |
|
156 | 23 | const jsDocParser = new Parser(); |
157 | 24 | jsDocParser.setLanguage(JSDoc); |
158 | | - const commentDescriptionQuery = new Parser.Query(JSDoc, |
159 | | - `(document |
160 | | - (description) @description)`); |
161 | | - const slotsQuery = new Parser.Query(Vue, |
162 | | - `[ |
163 | | - (self_closing_tag |
164 | | - (tag_name) @tag |
165 | | - (attribute |
166 | | - (attribute_name) @attr.name |
167 | | - (quoted_attribute_value (attribute_value) @attr.value))? |
168 | | - (#eq? @tag "slot") |
169 | | - (#eq? @attr.name "name")) |
170 | | - (start_tag |
171 | | - (tag_name) @tag |
172 | | - (attribute |
173 | | - (attribute_name) @attr.name |
174 | | - (quoted_attribute_value (attribute_value) @attr.value))? |
175 | | - (#eq? @tag "slot") |
176 | | - (#eq? @attr.name "name")) |
177 | | - ]`); |
178 | 25 |
|
179 | 26 | const symbols = []; |
180 | 27 | const relationships = []; |
181 | | - const identifiers = new Set(); |
182 | | - |
183 | | - const rootDir = path.join(__dirname, '..'); |
184 | | - const componentsDir = path.join(rootDir, 'src/components'); |
185 | | - for await (const filepath of findVueFiles(componentsDir)) { |
186 | | - const contents = await fs.promises.readFile(filepath, { encoding: 'utf8' }); |
187 | | - const vueTree = vueParser.parse(contents); |
188 | | - |
189 | | - const { script } = uniqueCaptures(scriptTextQuery, vueTree); |
190 | | - if (script) { |
191 | | - const jsTree = jsParser.parse(script.text); |
192 | | - |
193 | | - const { comment, component } = uniqueCaptures(exportNameQuery, jsTree); |
194 | | - |
195 | | - if (component) { |
196 | | - const componentName = component.text; |
197 | | - const pathComponents = filepath |
198 | | - .replace(componentsDir, '') |
199 | | - .split('/') |
200 | | - .filter(part => part.length) |
201 | | - .map(part => path.parse(part).name); |
202 | | - const preciseIdentifier = pathComponents.join(''); |
203 | | - |
204 | | - const subHeading = [ |
205 | | - Token.text('<'), |
206 | | - Token.identifier(componentName), |
207 | | - Token.text('>'), |
208 | | - ]; |
209 | | - |
210 | | - let functionSignature; |
211 | | - const captures = exportPropsQuery.captures(jsTree.rootNode); |
212 | | - const params = captures.reduce((memo, capture) => { |
213 | | - if (capture.name === 'prop.name') { |
214 | | - memo.push({ name: capture.node.text }); |
215 | | - } |
216 | | - if (capture.name === 'prop.type') { |
217 | | - // eslint-disable-next-line no-param-reassign |
218 | | - memo[memo.length - 1].type = capture.node.text; |
219 | | - } |
220 | | - return memo; |
221 | | - }, []); |
222 | | - if (params.length) { |
223 | | - // not sure if DocC actually uses `functionSignature` or not... |
224 | | - functionSignature = { |
225 | | - parameters: params.map(param => ({ |
226 | | - name: param.name, |
227 | | - declarationFragments: [Token.text(param.type)], |
228 | | - })), |
229 | | - }; |
230 | | - } |
231 | | - |
232 | | - // TODO: eventually we should also capture slots that are expressed in |
233 | | - // a render function instead of the template |
234 | | - const slots = slotsQuery.captures(vueTree.rootNode).reduce((memo, capture) => { |
235 | | - if (capture.name === 'tag') { |
236 | | - memo.push({ name: 'default' }); |
237 | | - } |
238 | | - if (capture.name === 'attr.value') { |
239 | | - // eslint-disable-next-line no-param-reassign |
240 | | - memo[memo.length - 1].name = capture.node.text; |
241 | | - } |
242 | | - return memo; |
243 | | - }, []); |
244 | | - const slotNames = [...new Set(slots.map(slot => slot.name))]; |
245 | | - const declarationFragments = createDeclaration(componentName, slotNames); |
246 | | - |
247 | | - let docComment; |
248 | | - let description; |
249 | | - if (comment) { |
250 | | - const jsDocTree = jsDocParser.parse(comment.text); |
251 | | - description = uniqueCaptures(commentDescriptionQuery, jsDocTree).description; |
252 | | - } |
253 | | - if (!!description || params.length) { |
254 | | - docComment = createDocComment(description, params); |
255 | | - } |
256 | | - |
257 | | - symbols.push({ |
258 | | - accessLevel: 'public', |
259 | | - identifier: { |
260 | | - interfaceLanguage: 'vue', |
261 | | - precise: preciseIdentifier, |
262 | | - }, |
263 | | - kind: { |
264 | | - identifier: 'class', // FIXME |
265 | | - displayName: 'Component', |
266 | | - }, |
267 | | - names: { |
268 | | - title: componentName, |
269 | | - subHeading, |
270 | | - }, |
271 | | - pathComponents, |
272 | | - docComment, |
273 | | - declarationFragments, |
274 | | - functionSignature, |
275 | | - }); |
276 | | - identifiers.add(preciseIdentifier); |
277 | | - } |
278 | | - } |
279 | | - } |
280 | | - |
281 | | - // construct parent/child relationships and fixup the `pathComponents` for |
282 | | - // each symbol so that it only contains items that map to real symbols (TODO: |
283 | | - // this could probably be done in the first loop depending on the order that |
284 | | - // `find` traverses the filesystem (breadth vs depth)) |
285 | | - for (let i = 0; i < symbols.length; i += 1) { |
286 | | - const symbol = symbols[i]; |
287 | | - const { |
288 | | - identifier: { precise: childIdentifier }, |
289 | | - pathComponents, |
290 | | - } = symbol; |
291 | | - const parentPathComponents = pathComponents.slice(0, pathComponents.length - 1); |
292 | | - if (!parentPathComponents.length) { |
293 | | - // eslint-disable-next-line no-continue |
294 | | - continue; |
295 | | - } |
296 | | - |
297 | | - const parentIdentifier = parentPathComponents.join(''); |
298 | | - if (identifiers.has(parentIdentifier)) { |
299 | | - relationships.push({ |
300 | | - source: childIdentifier, |
301 | | - target: parentIdentifier, |
302 | | - kind: 'memberOf', |
303 | | - }); |
304 | | - } else { |
305 | | - symbol.pathComponents = pathComponents.filter((_, j) => ( |
306 | | - identifiers.has(pathComponents.slice(0, j + 1).join('')) |
307 | | - )); |
308 | | - } |
309 | | - } |
310 | 28 |
|
311 | 29 | const formatVersion = { |
312 | 30 | major: 0, |
|
0 commit comments