expo-modules-test-core
Version:
Module providing native testing utilities for testing Expo modules
282 lines (259 loc) • 9.38 kB
text/typescript
// convert requires above to imports
import { execSync } from 'child_process';
import fsNode from 'fs';
import { globSync } from 'glob';
import XML from 'xml-js';
import YAML from 'yaml';
import {
Closure,
CursorInfoOutput,
FileType,
FullyAnnotatedDecl,
OutputModuleDefinition,
Structure,
} from './types';
const rootDir = process.cwd();
const pattern = `${rootDir}/**/*.swift`;
function getStructureFromFile(file: FileType) {
const command = 'sourcekitten structure --file ' + file.path;
try {
const output = execSync(command);
return JSON.parse(output.toString());
} catch (error) {
console.error('An error occurred while executing the command:', error);
}
}
// find an object with "key.typename" : "ModuleDefinition" somewhere in the structure and return it
function findModuleDefinitionInStructure(structure: Structure): Structure[] | null {
if (!structure) {
return null;
}
if (structure?.['key.typename'] === 'ModuleDefinition') {
const root = structure?.['key.substructure'];
if (!root) {
console.warn('Found ModuleDefinition but it is malformed');
}
return root;
}
const substructure = structure['key.substructure'];
if (Array.isArray(substructure) && substructure.length > 0) {
for (const child of substructure) {
let result = null;
result = findModuleDefinitionInStructure(child);
if (result) {
return result;
}
}
}
return null;
}
// Read string straight from file – needed since we can't get cursorinfo for modulename
function getIdentifierFromOffsetObject(offsetObject: Structure, file: FileType) {
// adding 1 and removing 1 to get rid of quotes
return file.content
.substring(offsetObject['key.offset'], offsetObject['key.offset'] + offsetObject['key.length'])
.replaceAll('"', '');
}
function maybeUnwrapXMLStructs(type: string | Partial<{ _text: string; 'ref.struct': string }>) {
if (!type) {
return type;
}
if (typeof type === 'string') {
return type;
}
if (type['_text']) {
return type['_text'];
}
if (type['ref.struct']) {
return maybeUnwrapXMLStructs(type['ref.struct']);
}
return type;
}
function maybeWrapArray<T>(itemOrItems: T[] | T | null) {
if (!itemOrItems) {
return null;
}
if (Array.isArray(itemOrItems)) {
return itemOrItems;
} else {
return [itemOrItems];
}
}
function parseXMLAnnotatedDeclarations(cursorInfoOutput: CursorInfoOutput) {
const xml = cursorInfoOutput['key.fully_annotated_decl'];
if (!xml) {
return null;
}
const parsed = XML.xml2js(xml, { compact: true }) as FullyAnnotatedDecl;
const parameters =
maybeWrapArray(parsed?.['decl.function.free']?.['decl.var.parameter'])?.map((p) => ({
name: maybeUnwrapXMLStructs(p['decl.var.parameter.argument_label']),
typename: maybeUnwrapXMLStructs(p['decl.var.parameter.type']),
})) ?? [];
const returnType = maybeUnwrapXMLStructs(
parsed?.['decl.function.free']?.['decl.function.returntype']
);
return { parameters, returnType };
}
let cachedSDKPath: string | null = null;
function getSDKPath() {
if (cachedSDKPath) {
return cachedSDKPath;
}
const sdkPath = execSync('xcrun --sdk iphoneos --show-sdk-path').toString().trim();
cachedSDKPath = sdkPath;
return cachedSDKPath;
}
// Read type description with sourcekitten, works only for variables
function getTypeFromOffsetObject(offsetObject: Structure, file: FileType) {
if (!offsetObject) {
return null;
}
const request = {
'key.request': 'source.request.cursorinfo',
'key.sourcefile': file.path,
'key.offset': offsetObject['key.offset'],
'key.compilerargs': [file.path, '-target', 'arm64-apple-ios', '-sdk', getSDKPath()],
};
const yamlRequest = YAML.stringify(request, {
defaultStringType: 'QUOTE_DOUBLE',
lineWidth: 0,
defaultKeyType: 'PLAIN',
// needed since behaviour of sourcekitten is not consistent
} as any).replace('"source.request.cursorinfo"', 'source.request.cursorinfo');
const command = 'sourcekitten request --yaml "' + yamlRequest.replaceAll('"', '\\"') + '"';
try {
const output = execSync(command, { stdio: 'pipe' });
return parseXMLAnnotatedDeclarations(JSON.parse(output.toString()));
} catch (error) {
console.error('An error occurred while executing the command:', error);
}
return null;
}
function hasSubstructure(structureObject: Structure) {
return structureObject?.['key.substructure'] && structureObject['key.substructure'].length > 0;
}
function parseClosureTypes(structureObject: Structure) {
const closure = structureObject['key.substructure']?.find(
(s) => s['key.kind'] === 'source.lang.swift.expr.closure'
);
if (!closure) {
return null;
}
const parameters = closure['key.substructure']
?.filter((s) => s['key.kind'] === 'source.lang.swift.decl.var.parameter')
.map((p) => ({ name: p['key.name'], typename: p['key.typename'] }));
const returnType = closure?.['key.typename'] ?? 'unknown';
return { parameters, returnType };
}
// Used for functions,async functions, all of shape Identifier(name, closure or function)
function findNamedDefinitionsOfType(type: string, moduleDefinition: Structure[], file: FileType) {
const definitionsOfType = moduleDefinition.filter((md) => md['key.name'] === type);
return definitionsOfType.map((d) => {
const definitionParams = d['key.substructure'];
const name = getIdentifierFromOffsetObject(definitionParams[0], file);
let types = null;
if (hasSubstructure(definitionParams[1])) {
types = parseClosureTypes(definitionParams[1]);
} else {
types = getTypeFromOffsetObject(definitionParams[1], file);
}
return { name, types };
});
}
// Used for events
function findGroupedDefinitionsOfType(type: string, moduleDefinition: Structure[], file: FileType) {
const definitionsOfType = moduleDefinition.filter((md) => md['key.name'] === type);
return definitionsOfType.flatMap((d) => {
const definitionParams = d['key.substructure'];
return definitionParams.map((d) => ({ name: getIdentifierFromOffsetObject(d, file) }));
});
}
function findAndParseNestedClassesOfType(
moduleDefinition: Structure[],
file: FileType,
type: string
) {
// we support reading definitions from closure only
const definitionsOfType = moduleDefinition.filter((md) => md['key.name'] === type);
return definitionsOfType
.map((df) => {
const nestedModuleDefinition =
df['key.substructure']?.[1]?.['key.substructure']?.[0]?.['key.substructure']?.[0]?.[
'key.substructure'
];
if (!nestedModuleDefinition) {
console.warn('Could not parse definition');
return null;
}
const name = getIdentifierFromOffsetObject(df['key.substructure']?.[0], file).replace(
'.self',
''
);
// let's drop nested view field and classes (are null anyways)
const {
views: _,
classes: _2,
...definition
} = parseModuleDefinition(nestedModuleDefinition, file);
return { ...definition, name };
})
.flatMap((f) => (f ? [f] : []));
}
function omitParamsFromClosureArguments<T extends Closure>(
definitions: T[],
paramsToOmit: string[]
) {
return definitions.map((d) => ({
...d,
types: {
...d.types,
parameters: d.types?.parameters?.filter((t, idx) => !paramsToOmit.includes(t.name)) ?? [],
},
}));
}
// Some blocks have additional modifiers like runOnQueue – we may need to do additional traversing to get to the function definition
function parseBlockModifiers(structureObject: Structure) {
if (structureObject['key.name']?.includes('runOnQueue')) {
return structureObject['key.substructure'][0];
}
return structureObject;
}
function parseModuleDefinition(
moduleDefinition: Structure[],
file: FileType
): OutputModuleDefinition {
const preparedModuleDefinition = moduleDefinition.map(parseBlockModifiers);
const parsedDefinition = {
name: findNamedDefinitionsOfType('Name', preparedModuleDefinition, file)?.[0]?.name,
functions: findNamedDefinitionsOfType('Function', preparedModuleDefinition, file),
asyncFunctions: omitParamsFromClosureArguments(
findNamedDefinitionsOfType('AsyncFunction', preparedModuleDefinition, file),
['promise']
),
events: findGroupedDefinitionsOfType('Events', preparedModuleDefinition, file),
properties: findNamedDefinitionsOfType('Property', preparedModuleDefinition, file),
props: omitParamsFromClosureArguments(
findNamedDefinitionsOfType('Prop', preparedModuleDefinition, file),
['view']
),
views: findAndParseNestedClassesOfType(preparedModuleDefinition, file, 'View'),
classes: findAndParseNestedClassesOfType(preparedModuleDefinition, file, 'Class'),
};
return parsedDefinition;
}
function findModuleDefinitionsInFiles(files: string[]) {
const modules = [];
for (const path of files) {
const file = { path, content: fsNode.readFileSync(path, 'utf8') };
const definition = findModuleDefinitionInStructure(getStructureFromFile(file));
if (definition) {
modules.push(parseModuleDefinition(definition, file));
}
}
return modules;
}
export function getAllExpoModulesInWorkingDirectory() {
const files = globSync(pattern);
return findModuleDefinitionsInFiles(files);
}