@plexcord-companion/ast-parser 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/AstParser.d.ts +131 -0
- package/dist/AstParser.js +370 -0
- package/dist/AstParser.js.map +1 -0
- package/dist/StringifiedModule.d.ts +3 -0
- package/dist/StringifiedModule.js +2 -0
- package/dist/StringifiedModule.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/util.d.ts +144 -0
- package/dist/util.js +404 -0
- package/dist/util.js.map +1 -0
- package/package.json +33 -0
- package/src/AstParser.ts +450 -0
- package/src/StringifiedModule.ts +3 -0
- package/src/index.ts +3 -0
- package/src/types.ts +31 -0
- package/src/util.ts +571 -0
package/src/AstParser.ts
ADDED
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
import { Format } from "@sadan4/devtools-pretty-printer";
|
|
2
|
+
import { collectVariableUsage, type VariableInfo } from "ts-api-utils";
|
|
3
|
+
import {
|
|
4
|
+
createSourceFile,
|
|
5
|
+
type Expression,
|
|
6
|
+
type Identifier,
|
|
7
|
+
isFunctionLike,
|
|
8
|
+
isLeftHandSideExpression,
|
|
9
|
+
isPropertyAccessExpression,
|
|
10
|
+
isVariableDeclaration,
|
|
11
|
+
isVariableDeclarationList,
|
|
12
|
+
type LeftHandSideExpression,
|
|
13
|
+
type MemberName,
|
|
14
|
+
type Node,
|
|
15
|
+
type PropertyAccessExpression,
|
|
16
|
+
type ReadonlyTextRange,
|
|
17
|
+
ScriptKind,
|
|
18
|
+
ScriptTarget,
|
|
19
|
+
type SourceFile,
|
|
20
|
+
SyntaxKind,
|
|
21
|
+
} from "typescript";
|
|
22
|
+
|
|
23
|
+
import { Cache, CacheGetter } from "@plexcord-companion/shared/decorators";
|
|
24
|
+
import { type Logger, NoopLogger } from "@plexcord-companion/shared/Logger";
|
|
25
|
+
import { type IPosition, Position } from "@plexcord-companion/shared/Position";
|
|
26
|
+
import { Range } from "@plexcord-companion/shared/Range";
|
|
27
|
+
|
|
28
|
+
import type { StringifiedModule } from "./StringifiedModule";
|
|
29
|
+
import type { Functionish } from "./types";
|
|
30
|
+
import { CharCode, findParent, getTokenAtPosition, isAssignmentExpression, isEOL } from "./util";
|
|
31
|
+
|
|
32
|
+
let logger: Logger = NoopLogger;
|
|
33
|
+
|
|
34
|
+
export function setLogger(newLogger: Logger): void {
|
|
35
|
+
logger = newLogger;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class AstParser {
|
|
39
|
+
public static withFormattedText(text: string): AstParser {
|
|
40
|
+
return new this(Format(text));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
public readonly text: string;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @CacheGetter
|
|
47
|
+
*/
|
|
48
|
+
@CacheGetter()
|
|
49
|
+
public get sourceFile(): SourceFile {
|
|
50
|
+
return this.createSourceFile();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* All the variables in the source file
|
|
55
|
+
* @CacheGetter
|
|
56
|
+
*/
|
|
57
|
+
@CacheGetter()
|
|
58
|
+
public get vars(): Map<Identifier, VariableInfo> {
|
|
59
|
+
return collectVariableUsage(this.sourceFile);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @CacheGetter
|
|
64
|
+
*/
|
|
65
|
+
@CacheGetter()
|
|
66
|
+
public get usesToVars(): Map<Identifier, VariableInfo> {
|
|
67
|
+
const map = new Map<Identifier, VariableInfo>();
|
|
68
|
+
|
|
69
|
+
for (const [, info] of this.vars) {
|
|
70
|
+
for (const { location } of info.uses) {
|
|
71
|
+
map.set(location, info);
|
|
72
|
+
}
|
|
73
|
+
// for (const decl of info.declarations) {
|
|
74
|
+
// map.set(decl, info);
|
|
75
|
+
// }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return map;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public getVarInfoFromUse(ident: Identifier): VariableInfo | undefined {
|
|
82
|
+
return this.usesToVars.get(ident);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// FIXME: add tests for this
|
|
86
|
+
/**
|
|
87
|
+
* @param use a use of a variable
|
|
88
|
+
* @param decl a declaration of a variable
|
|
89
|
+
* @returns true of the use is a use of the declaration, false otherwise
|
|
90
|
+
*/
|
|
91
|
+
public isUseOf(use: Identifier | undefined, decl: Identifier | undefined): boolean {
|
|
92
|
+
if (!decl || !use)
|
|
93
|
+
return false;
|
|
94
|
+
|
|
95
|
+
const varInfo = this.vars.get(decl);
|
|
96
|
+
|
|
97
|
+
if (!varInfo)
|
|
98
|
+
return false;
|
|
99
|
+
|
|
100
|
+
const varInfoFromUse = this.usesToVars.get(use);
|
|
101
|
+
|
|
102
|
+
return varInfoFromUse === varInfo;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public constructor(text: string) {
|
|
106
|
+
this.text = text;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* given something like this
|
|
111
|
+
* ```js
|
|
112
|
+
* const bar = "foo";
|
|
113
|
+
* const baz = bar;
|
|
114
|
+
* const qux = baz;
|
|
115
|
+
* ```
|
|
116
|
+
* if given `qux` it will return `[bar, baz]`;
|
|
117
|
+
*
|
|
118
|
+
* fails on something where a variable is reassigned
|
|
119
|
+
*/
|
|
120
|
+
public unwrapVariableDeclaration(ident: Identifier): Identifier[] | undefined {
|
|
121
|
+
const arr: Identifier[] = [];
|
|
122
|
+
let last = ident;
|
|
123
|
+
|
|
124
|
+
while (true) {
|
|
125
|
+
const [varDec, ...rest] = this.getVarInfoFromUse(last)?.declarations ?? [];
|
|
126
|
+
|
|
127
|
+
if (!varDec)
|
|
128
|
+
break;
|
|
129
|
+
if (rest.length) {
|
|
130
|
+
arr.length = 0;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
arr.push(last = varDec);
|
|
134
|
+
}
|
|
135
|
+
if (arr.length !== 0)
|
|
136
|
+
return arr;
|
|
137
|
+
logger.debug("[AstParser] Failed finding variable declaration");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Used for interop with other systems
|
|
142
|
+
*/
|
|
143
|
+
// FIXME: PACKAGE -
|
|
144
|
+
public serialize(): StringifiedModule {
|
|
145
|
+
return {
|
|
146
|
+
content: this.text,
|
|
147
|
+
} satisfies StringifiedModule;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* given the `x` of
|
|
152
|
+
* ```js
|
|
153
|
+
* const x = {
|
|
154
|
+
* foo: bar
|
|
155
|
+
* }
|
|
156
|
+
* ```
|
|
157
|
+
* NOTE: this must be the exact x, not a use of it
|
|
158
|
+
* @returns the expression {foo: bar}
|
|
159
|
+
*/
|
|
160
|
+
public getVariableInitializer(ident: Identifier): Expression | undefined {
|
|
161
|
+
const dec = ident.parent;
|
|
162
|
+
|
|
163
|
+
if (!isVariableDeclaration(dec))
|
|
164
|
+
return;
|
|
165
|
+
return dec.initializer;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* TODO: document this
|
|
170
|
+
*/
|
|
171
|
+
public isConstDeclared(info: VariableInfo): [Identifier] | false {
|
|
172
|
+
const len = info.declarations.length;
|
|
173
|
+
|
|
174
|
+
if (len !== 1) {
|
|
175
|
+
if (len > 1) {
|
|
176
|
+
logger.warn("[AstParser] isConstDeclared: ?????");
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const [decl] = info.declarations;
|
|
182
|
+
const varDecl = findParent(decl, isVariableDeclarationList);
|
|
183
|
+
|
|
184
|
+
return ((varDecl?.flags ?? 0) & SyntaxKind.ConstKeyword) !== 0 ? [decl] : false;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// TODO: add tests for this
|
|
188
|
+
/**
|
|
189
|
+
* @param expr the property access expression to flatten
|
|
190
|
+
*
|
|
191
|
+
* given a property access expression like `foo.bar.baz.qux`
|
|
192
|
+
*
|
|
193
|
+
* @returns the identifiers [`foo`, `bar`, `baz`, `qux`]
|
|
194
|
+
*
|
|
195
|
+
* given another property access expression like `foo.bar.baz[0].qux.abc`
|
|
196
|
+
*
|
|
197
|
+
* @returns the elementAccessExpression, followed by the identifiers [`foo.bar.baz[0]`, `qux`, `abc`]
|
|
198
|
+
*/
|
|
199
|
+
public flattenPropertyAccessExpression(expr: PropertyAccessExpression | undefined):
|
|
200
|
+
| readonly [LeftHandSideExpression, ...MemberName[]]
|
|
201
|
+
| undefined {
|
|
202
|
+
if (!expr)
|
|
203
|
+
return undefined;
|
|
204
|
+
|
|
205
|
+
const toRet = [] as any as [LeftHandSideExpression, ...MemberName[]];
|
|
206
|
+
let cur = expr;
|
|
207
|
+
|
|
208
|
+
do {
|
|
209
|
+
toRet.unshift(cur.name);
|
|
210
|
+
if (isLeftHandSideExpression(cur.expression) && !isPropertyAccessExpression(cur.expression)) {
|
|
211
|
+
toRet.unshift(cur.expression);
|
|
212
|
+
return toRet;
|
|
213
|
+
}
|
|
214
|
+
if (!isPropertyAccessExpression(cur.expression)) {
|
|
215
|
+
toRet.unshift(cur.expression);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
} while ((cur = cur.expression));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* given a variable, if it has a single assignment in this file, return the expression assigned to it
|
|
223
|
+
*
|
|
224
|
+
* returns undefined if there are multiple assignments, or if the variable is assigned more than once
|
|
225
|
+
*/
|
|
226
|
+
public findSingleAssignment(info: VariableInfo): Expression | undefined {
|
|
227
|
+
const { declarations, uses } = info;
|
|
228
|
+
|
|
229
|
+
if (declarations.length !== 1) {
|
|
230
|
+
logger.warn("[AstParser] findSingleAssignment: multiple declarations");
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const [decl] = declarations;
|
|
235
|
+
|
|
236
|
+
if (this.isConstDeclared(info)) {
|
|
237
|
+
const init = this.getVariableInitializer(decl);
|
|
238
|
+
|
|
239
|
+
if (!init) {
|
|
240
|
+
logger.warn("[AstParser] findSingleAssignment: const variable without initializer");
|
|
241
|
+
}
|
|
242
|
+
return init;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
let init: Expression | undefined;
|
|
246
|
+
|
|
247
|
+
for (const { location } of uses) {
|
|
248
|
+
if (isAssignmentExpression(location.parent)) {
|
|
249
|
+
// filter out cases like `<some other thing> = location`
|
|
250
|
+
if (location.parent.left !== location) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
if (init || location.parent.operatorToken.kind !== SyntaxKind.EqualsToken) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
init = location.parent.right;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return init;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Create the source file for this parser
|
|
265
|
+
*
|
|
266
|
+
* MUST SET PARENT NODES
|
|
267
|
+
* @Cache
|
|
268
|
+
*/
|
|
269
|
+
@Cache()
|
|
270
|
+
protected createSourceFile(): SourceFile {
|
|
271
|
+
return createSourceFile(
|
|
272
|
+
"file.tsx",
|
|
273
|
+
this.text,
|
|
274
|
+
ScriptTarget.ESNext,
|
|
275
|
+
true,
|
|
276
|
+
ScriptKind.TSX,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Returns the token at or following the specified position or undefined if none is found inside `parent`. */
|
|
281
|
+
public getTokenAtOffset(pos: number): Node | undefined {
|
|
282
|
+
return getTokenAtPosition(this.sourceFile, pos, this.sourceFile, false);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
public getTokenAtPosition(pos: IPosition): Node | undefined {
|
|
286
|
+
return this.getTokenAtOffset(this.offsetAt(pos));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* convert two offsets to a range
|
|
291
|
+
*
|
|
292
|
+
* **DO NOT USE WITH AN AST NODE, IT WILL LEAD TO INCORRECT LOCATIONS**
|
|
293
|
+
* @see makeRangeFromAstNode
|
|
294
|
+
*/
|
|
295
|
+
public makeRange({ pos, end }: ReadonlyTextRange): Range {
|
|
296
|
+
return new Range(this.positionAt(pos), this.positionAt(end));
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
public makeRangeFromAstNode(node: Node): Range {
|
|
300
|
+
return new Range(this.positionAt(node.getStart(this.sourceFile)), this.positionAt(node.end));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
public makeRangeFromAnonFunction(func: Functionish): Range {
|
|
304
|
+
const { pos } = func.body ?? { pos: func.getEnd() };
|
|
305
|
+
|
|
306
|
+
return this.makeRange({
|
|
307
|
+
pos: func.getStart(),
|
|
308
|
+
end: pos,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
public makeRangeFromFunctionDef(ident: Identifier): Range | undefined {
|
|
313
|
+
const { declarations } = this.getVarInfoFromUse(ident) ?? {};
|
|
314
|
+
|
|
315
|
+
if (!declarations) {
|
|
316
|
+
logger.debug("makeRangeFromFunctionDef: no declarations found for identifier");
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
if (declarations.length !== 1) {
|
|
320
|
+
logger.debug("makeRangeFromFunctionDef: zero or multiple declarations found for identifier");
|
|
321
|
+
return undefined;
|
|
322
|
+
}
|
|
323
|
+
if (declarations[0].parent && !isFunctionLike(declarations[0].parent)) {
|
|
324
|
+
logger.debug("makeRangeFromFunctionDef: dec. parent is not a function");
|
|
325
|
+
return undefined;
|
|
326
|
+
}
|
|
327
|
+
return this.makeRangeFromAstNode(declarations[0]);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Converts the position to a zero-based offset.
|
|
333
|
+
* Invalid positions are adjusted as described in {@link Position.line}
|
|
334
|
+
* and {@link Position.character}.
|
|
335
|
+
*
|
|
336
|
+
* @param position A position.
|
|
337
|
+
* @return A valid zero-based offset.
|
|
338
|
+
*/
|
|
339
|
+
// copied from vscode-languageserver-node
|
|
340
|
+
public offsetAt(position: IPosition): number {
|
|
341
|
+
const { lineOffsets } = this;
|
|
342
|
+
|
|
343
|
+
if (position.line >= lineOffsets.length) {
|
|
344
|
+
return this.text.length;
|
|
345
|
+
} else if (position.line < 0) {
|
|
346
|
+
return 0;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const lineOffset = lineOffsets[position.line];
|
|
350
|
+
|
|
351
|
+
if (position.character <= 0) {
|
|
352
|
+
return lineOffset;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const nextLineOffset
|
|
356
|
+
= position.line + 1 < lineOffsets.length
|
|
357
|
+
? lineOffsets[position.line + 1]
|
|
358
|
+
: this.text.length;
|
|
359
|
+
|
|
360
|
+
const offset = Math.min(lineOffset + position.character, nextLineOffset);
|
|
361
|
+
|
|
362
|
+
return this.ensureBeforeEOL(offset, lineOffset);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// methods copied from vscode-languageserver-node
|
|
366
|
+
/**
|
|
367
|
+
* @CacheGetter
|
|
368
|
+
*/
|
|
369
|
+
@CacheGetter()
|
|
370
|
+
private get lineOffsets() {
|
|
371
|
+
return this.computeLineOffsets(true);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
@CacheGetter()
|
|
375
|
+
/**
|
|
376
|
+
* @CacheGetter
|
|
377
|
+
*/
|
|
378
|
+
public get lineCount(): number {
|
|
379
|
+
return this.lineOffsets.length;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private ensureBeforeEOL(offset: number, lineOffset: number): number {
|
|
383
|
+
while (offset > lineOffset && isEOL(this.text.charCodeAt(offset - 1))) {
|
|
384
|
+
offset--;
|
|
385
|
+
}
|
|
386
|
+
return offset;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
private computeLineOffsets(isAtLineStart: boolean, textOffset = 0): number[] {
|
|
390
|
+
const { text } = this;
|
|
391
|
+
const result: number[] = isAtLineStart ? [textOffset] : [];
|
|
392
|
+
|
|
393
|
+
for (let i = 0; i < text.length; i++) {
|
|
394
|
+
const ch = text.charCodeAt(i);
|
|
395
|
+
|
|
396
|
+
if (isEOL(ch)) {
|
|
397
|
+
if (
|
|
398
|
+
ch === CharCode.CarriageReturn
|
|
399
|
+
&& i + 1 < text.length
|
|
400
|
+
&& text.charCodeAt(i + 1) === CharCode.LineFeed
|
|
401
|
+
) {
|
|
402
|
+
i++;
|
|
403
|
+
}
|
|
404
|
+
result.push(textOffset + i + 1);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return result;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Converts a zero-based offset to a position.
|
|
412
|
+
*
|
|
413
|
+
* @param offset A zero-based offset.
|
|
414
|
+
* @return A valid {@link Position position}.
|
|
415
|
+
* @example The text document "ab\ncd" produces:
|
|
416
|
+
* position { line: 0, character: 0 } for `offset` 0.
|
|
417
|
+
* position { line: 0, character: 1 } for `offset` 1.
|
|
418
|
+
* position { line: 0, character: 2 } for `offset` 2.
|
|
419
|
+
* position { line: 1, character: 0 } for `offset` 3.
|
|
420
|
+
* position { line: 1, character: 1 } for `offset` 4.
|
|
421
|
+
*/
|
|
422
|
+
public positionAt(offset: number): Position {
|
|
423
|
+
offset = Math.max(Math.min(offset, this.text.length), 0);
|
|
424
|
+
|
|
425
|
+
const { lineOffsets } = this;
|
|
426
|
+
|
|
427
|
+
let low = 0,
|
|
428
|
+
high = lineOffsets.length;
|
|
429
|
+
|
|
430
|
+
if (high === 0) {
|
|
431
|
+
return new Position(0, offset);
|
|
432
|
+
}
|
|
433
|
+
while (low < high) {
|
|
434
|
+
const mid = Math.floor((low + high) / 2);
|
|
435
|
+
|
|
436
|
+
if (lineOffsets[mid] > offset) {
|
|
437
|
+
high = mid;
|
|
438
|
+
} else {
|
|
439
|
+
low = mid + 1;
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// low is the least x for which the line offset is larger than the current offset
|
|
444
|
+
// or array.length if no line offset is larger than the current offset
|
|
445
|
+
const line = low - 1;
|
|
446
|
+
|
|
447
|
+
offset = this.ensureBeforeEOL(offset, lineOffsets[line]);
|
|
448
|
+
return new Position(line, offset - lineOffsets[line]);
|
|
449
|
+
}
|
|
450
|
+
}
|
package/src/index.ts
ADDED
package/src/types.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ArrowFunction, FunctionExpression, FunctionLikeDeclaration, Identifier, ModuleExportName, Node } from "typescript";
|
|
2
|
+
|
|
3
|
+
export type Functionish = FunctionLikeDeclaration | ArrowFunction | FunctionExpression;
|
|
4
|
+
export type AnyFunction = FunctionExpression | ArrowFunction;
|
|
5
|
+
|
|
6
|
+
export type AssertedType<
|
|
7
|
+
T extends Function,
|
|
8
|
+
E = any,
|
|
9
|
+
>
|
|
10
|
+
= T extends (a: any) => a is infer R ? R extends E ? R : never : never;
|
|
11
|
+
|
|
12
|
+
export type CBAssertion<U = undefined, N = never> = <
|
|
13
|
+
F extends (n: Node) => n is Node,
|
|
14
|
+
R extends Node = AssertedType<F, Node>,
|
|
15
|
+
>(
|
|
16
|
+
node: Node | N,
|
|
17
|
+
func: F extends (n: Node) => n is R ? F : never
|
|
18
|
+
) => R | U;
|
|
19
|
+
|
|
20
|
+
export type Import = {
|
|
21
|
+
default: boolean;
|
|
22
|
+
source: string;
|
|
23
|
+
namespace: boolean;
|
|
24
|
+
orig?: ModuleExportName;
|
|
25
|
+
as: Identifier;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type WithParent<N, P> = Omit<N, "parent"> & {
|
|
29
|
+
parent: P;
|
|
30
|
+
};
|
|
31
|
+
|