@sentry/wizard
Version:
Sentry wizard helping you to configure your project
271 lines (245 loc) • 7.95 kB
text/typescript
import * as fs from 'fs';
import * as recast from 'recast';
import x = recast.types;
import t = x.namedTypes;
const b = recast.types.builders;
/**
* Checks if a file where we don't know its concrete file type yet exists
* and returns the full path to the file with the correct file type.
*/
export function findFile(
filePath: string,
fileTypes: string[] = ['.js', '.ts', '.mjs', '.cjs'],
): string | undefined {
return fileTypes
.map((type) => `${filePath}${type}`)
.find((file) => fs.existsSync(file));
}
/**
* checks for require('@sentry/*') syntax
*/
export function hasSentryContent(program: t.Program): boolean {
let foundSentry: boolean | undefined = false;
recast.visit(program, {
visitStringLiteral(path) {
foundSentry = foundSentry || path.node.value.startsWith('@sentry/');
this.traverse(path);
},
visitLiteral(path) {
foundSentry =
foundSentry || path.node.value?.toString().startsWith('@sentry/');
this.traverse(path);
},
});
return !!foundSentry;
}
/**
* Searches for a property of an ObjectExpression by name
*
* @param object the ObjectExpression to search in
* @param name the name of the property to search for
*
* @returns the property if it exists
*/
export function getObjectProperty(
object: t.ObjectExpression,
name: string,
): t.Property | undefined {
return object.properties.find((p): p is t.Property => {
const isObjectProp = p.type === 'Property' || p.type === 'ObjectProperty';
if (!isObjectProp) {
return false;
}
const hasMatchingLiteralKey =
isObjectProp &&
(p.key.type === 'Literal' || p.key.type === 'StringLiteral') &&
p.key.value === name;
if (hasMatchingLiteralKey) {
return true;
}
// has matching identifier key
return isObjectProp && p.key.type === 'Identifier' && p.key.name === name;
});
}
/**
* Attempts to find a property of an ObjectExpression by name. If it doesn't exist,
* the property will be added to the ObjectExpression with the provided default value.
*
* @param object the parent object expression to search in
* @param name the name of the property to search for
* @param defaultValue the default value to set if the property doesn't exist
*
* @returns the
*/
export function getOrSetObjectProperty(
object: t.ObjectExpression,
name: string,
defaultValue:
| t.Literal
| t.BooleanLiteral
| t.StringLiteral
| t.ObjectExpression,
): t.Property {
const existingProperty = getObjectProperty(object, name);
if (existingProperty) {
return existingProperty;
}
const newProperty = b.property.from({
kind: 'init',
key: b.stringLiteral(name),
value: defaultValue,
});
object.properties.push(newProperty);
return newProperty;
}
/**
* Sets a property of an ObjectExpression if it exists, otherwise adds it
* to the ObjectExpression. Optionally, a comment can be added to the
* property.
*
* @param object the ObjectExpression to set the property on
* @param name the name of the property to set
* @param value the value of the property to set
* @param comment (optional) a comment to add to the property
*/
export function setOrUpdateObjectProperty(
object: t.ObjectExpression,
name: string,
value: t.Literal | t.BooleanLiteral | t.StringLiteral | t.ObjectExpression,
comment?: string,
) {
const newComments =
comment &&
comment.split('\n').map((c) => b.commentLine(` ${c}`, true, false));
const existingProperty = getObjectProperty(object, name);
if (existingProperty) {
existingProperty.value = value;
if (newComments) {
existingProperty.comments = [
...(existingProperty?.comments || []),
...newComments,
];
}
} else {
object.properties.push(
b.objectProperty.from({
key: b.stringLiteral(name),
value,
...(newComments && {
comments: newComments,
}),
}),
);
}
}
type JsonCParseResult =
| {
jsonObject: t.ObjectExpression;
ast: t.Program;
}
| {
jsonObject: undefined;
ast: undefined;
};
/**
* Parses a JSON string with (potential) comments (JSON-C) and returns the JS AST
* that can be walked and modified with recast like a normal JS AST.
*
* This is done by wrapping the JSON-C string in parentheses, thereby making it
* a JS `Program` with an `ExpressionStatement` as its body. The expression is then
* extracted from the AST and returned alongside the AST.
*
* To preserve as much original formatting as possible, the returned `ast`
* property should be passed to {@link `printJsonC`} to get the JSON-C string back.
*
* If the input is not valid JSON-C, the result will be undefined.
*
* @see {@link JsonCParseResult}
*
* @param jsonString a JSON-C string
*
* @returns a {@link JsonCParseResult}, containing either the JSON-C object and the AST or undefined in both cases
*/
export function parseJsonC(jsonString: string): JsonCParseResult {
try {
const jsTsConfig = `(${jsonString})`;
// no idea why recast returns any here, this is dumb :/
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
const ast = recast.parse(jsTsConfig.toString()).program as t.Program;
const jsonObject =
(ast.body[0].type === 'ExpressionStatement' &&
ast.body[0].expression.type === 'ObjectExpression' &&
ast.body[0].expression) ||
undefined;
if (jsonObject) {
return { jsonObject, ast };
}
} catch {
/* empty */
}
return { jsonObject: undefined, ast: undefined };
}
/**
* Takes the AST of a parsed JSON-C "program" and returns the JSON-C string without
* any of the temporary JS wrapper code that was previously applied.
*
* Only use this in conjunction with {@link `parseJsonC`}
*
* @param ast the `ast` returned from {@link `parseJsonC`}
*
* @returns the JSON-C string
*/
export function printJsonC(ast: t.Program): string {
const js = recast.print(ast).code;
return js.substring(1, js.length - 1);
}
/**
* Walks the program body and returns index of the last variable assignment initialized by require statement.
* Only counts top level require statements.
*
* @returns index of the last `const foo = require('bar');` statement or -1 if none was found.
*/
export function getLastRequireIndex(program: t.Program): number {
let lastRequireIdex = -1;
program.body.forEach((s, i) => {
if (
s.type === 'VariableDeclaration' &&
s.declarations[0].type === 'VariableDeclarator' &&
s.declarations[0].init !== null &&
typeof s.declarations[0].init !== 'undefined' &&
s.declarations[0].init.type === 'CallExpression' &&
s.declarations[0].init.callee.type === 'Identifier' &&
s.declarations[0].init.callee.name === 'require'
) {
lastRequireIdex = i;
}
});
return lastRequireIdex;
}
/**
* Walks the statements and removes require statements which first argument includes the predicate.
* Only removes top level require statements like `const foo = require('bar');`
*
* @returns True if any require statement was removed.
*/
export function removeRequire(program: t.Program, predicate: string): boolean {
let removedAtLeastOne = false;
program.body = program.body.filter((s) => {
if (
s.type === 'VariableDeclaration' &&
s.declarations[0].type === 'VariableDeclarator' &&
s.declarations[0].init !== null &&
typeof s.declarations[0].init !== 'undefined' &&
s.declarations[0].init.type === 'CallExpression' &&
s.declarations[0].init.callee.type === 'Identifier' &&
s.declarations[0].init.callee.name === 'require' &&
s.declarations[0].init.arguments[0].type === 'StringLiteral' &&
s.declarations[0].init.arguments[0].value.includes(predicate)
) {
removedAtLeastOne = true;
return false;
}
return true;
});
return removedAtLeastOne;
}