1// convert requires above to imports
2import { execSync } from 'child_process';
3import fsNode from 'fs';
4import { globSync } from 'glob';
5import XML from 'xml-js';
6import YAML from 'yaml';
7
8import {
9  Closure,
10  CursorInfoOutput,
11  FileType,
12  FullyAnnotatedDecl,
13  OutputModuleDefinition,
14  OutputViewDefinition,
15  Structure,
16} from './types';
17
18const rootDir = process.cwd();
19const pattern = `${rootDir}/**/*.swift`;
20
21function getStructureFromFile(file: FileType) {
22  const command = 'sourcekitten structure --file ' + file.path;
23
24  try {
25    const output = execSync(command);
26    return JSON.parse(output.toString());
27  } catch (error) {
28    console.error('An error occurred while executing the command:', error);
29  }
30}
31// find an object with "key.typename" : "ModuleDefinition" somewhere in the structure and return it
32function findModuleDefinitionInStructure(structure: Structure): Structure[] | null {
33  if (!structure) {
34    return null;
35  }
36  if (structure?.['key.typename'] === 'ModuleDefinition') {
37    const root = structure?.['key.substructure'];
38    if (!root) {
39      console.warn('Found ModuleDefinition but it is malformed');
40    }
41    return root;
42  }
43  const substructure = structure['key.substructure'];
44  if (Array.isArray(substructure) && substructure.length > 0) {
45    for (const child of substructure) {
46      let result = null;
47      result = findModuleDefinitionInStructure(child);
48      if (result) {
49        return result;
50      }
51    }
52  }
53  return null;
54}
55
56// Read string straight from file – needed since we can't get cursorinfo for modulename
57function getIdentifierFromOffsetObject(offsetObject: Structure, file: FileType) {
58  // adding 1 and removing 1 to get rid of quotes
59  return file.content
60    .substring(offsetObject['key.offset'], offsetObject['key.offset'] + offsetObject['key.length'])
61    .replaceAll('"', '');
62}
63
64function maybeUnwrapXMLStructs(type: string | Partial<{ _text: string; 'ref.struct': string }>) {
65  if (!type) {
66    return type;
67  }
68  if (typeof type === 'string') {
69    return type;
70  }
71  if (type['_text']) {
72    return type['_text'];
73  }
74  if (type['ref.struct']) {
75    return maybeUnwrapXMLStructs(type['ref.struct']);
76  }
77  return type;
78}
79
80function maybeWrapArray<T>(itemOrItems: T[] | T | null) {
81  if (!itemOrItems) {
82    return null;
83  }
84  if (Array.isArray(itemOrItems)) {
85    return itemOrItems;
86  } else {
87    return [itemOrItems];
88  }
89}
90
91function parseXMLAnnotatedDeclarations(cursorInfoOutput: CursorInfoOutput) {
92  const xml = cursorInfoOutput['key.fully_annotated_decl'];
93  if (!xml) {
94    return null;
95  }
96  const parsed = XML.xml2js(xml, { compact: true }) as FullyAnnotatedDecl;
97
98  const parameters =
99    maybeWrapArray(parsed?.['decl.function.free']?.['decl.var.parameter'])?.map((p) => ({
100      name: maybeUnwrapXMLStructs(p['decl.var.parameter.argument_label']),
101      typename: maybeUnwrapXMLStructs(p['decl.var.parameter.type']),
102    })) ?? [];
103  const returnType = maybeUnwrapXMLStructs(
104    parsed?.['decl.function.free']?.['decl.function.returntype']
105  );
106  return { parameters, returnType };
107}
108
109let cachedSDKPath: string | null = null;
110function getSDKPath() {
111  if (cachedSDKPath) {
112    return cachedSDKPath;
113  }
114  const sdkPath = execSync('xcrun --sdk iphoneos --show-sdk-path').toString().trim();
115  cachedSDKPath = sdkPath;
116  return cachedSDKPath;
117}
118
119// Read type description with sourcekitten, works only for variables
120function getTypeFromOffsetObject(offsetObject: Structure, file: FileType) {
121  if (!offsetObject) {
122    return null;
123  }
124  const request = {
125    'key.request': 'source.request.cursorinfo',
126    'key.sourcefile': file.path,
127    'key.offset': offsetObject['key.offset'],
128    'key.compilerargs': [file.path, '-target', 'arm64-apple-ios', '-sdk', getSDKPath()],
129  };
130  const yamlRequest = YAML.stringify(request, {
131    defaultStringType: 'QUOTE_DOUBLE',
132    lineWidth: 0,
133    defaultKeyType: 'PLAIN',
134    // needed since behaviour of sourcekitten is not consistent
135  } as any).replace('"source.request.cursorinfo"', 'source.request.cursorinfo');
136
137  const command = 'sourcekitten request --yaml "' + yamlRequest.replaceAll('"', '\\"') + '"';
138  try {
139    const output = execSync(command, { stdio: 'pipe' });
140    return parseXMLAnnotatedDeclarations(JSON.parse(output.toString()));
141  } catch (error) {
142    console.error('An error occurred while executing the command:', error);
143  }
144  return null;
145}
146
147function hasSubstructure(structureObject: Structure) {
148  return structureObject?.['key.substructure'] && structureObject['key.substructure'].length > 0;
149}
150
151function parseClosureTypes(structureObject: Structure) {
152  const closure = structureObject['key.substructure']?.find(
153    (s) => s['key.kind'] === 'source.lang.swift.expr.closure'
154  );
155  if (!closure) {
156    return null;
157  }
158  const parameters = closure['key.substructure']
159    ?.filter((s) => s['key.kind'] === 'source.lang.swift.decl.var.parameter')
160    .map((p) => ({ name: p['key.name'], typename: p['key.typename'] }));
161
162  // TODO: Figure out if possible
163  const returnType = 'unknown';
164  return { parameters, returnType };
165}
166
167// Used for functions,async functions, all of shape Identifier(name, closure or function)
168function findNamedDefinitionsOfType(type: string, moduleDefinition: Structure[], file: FileType) {
169  const definitionsOfType = moduleDefinition.filter((md) => md['key.name'] === type);
170  return definitionsOfType.map((d) => {
171    const definitionParams = d['key.substructure'];
172    const name = getIdentifierFromOffsetObject(definitionParams[0], file);
173    let types = null;
174    if (hasSubstructure(definitionParams[1])) {
175      types = parseClosureTypes(definitionParams[1]);
176    } else {
177      types = getTypeFromOffsetObject(definitionParams[1], file);
178    }
179    return { name, types };
180  });
181}
182
183// Used for events
184function findGroupedDefinitionsOfType(type: string, moduleDefinition: Structure[], file: FileType) {
185  const definitionsOfType = moduleDefinition.filter((md) => md['key.name'] === type);
186  return definitionsOfType.flatMap((d) => {
187    const definitionParams = d['key.substructure'];
188    return definitionParams.map((d) => ({ name: getIdentifierFromOffsetObject(d, file) }));
189  });
190}
191
192function findAndParseView(
193  moduleDefinition: Structure[],
194  file: FileType
195): null | OutputViewDefinition {
196  const viewDefinition = moduleDefinition.find((md) => md['key.name'] === 'View');
197  if (!viewDefinition) {
198    return null;
199  }
200  // we support reading view definitions from closure only
201  const viewModuleDefinition =
202    viewDefinition['key.substructure']?.[1]?.['key.substructure']?.[0]?.['key.substructure']?.[0]?.[
203      'key.substructure'
204    ];
205  if (!viewModuleDefinition) {
206    console.warn('Could not parse view definition');
207    return null;
208  }
209  // let's drop nested view field (is null anyways)
210  const { view: _, ...definition } = parseModuleDefinition(viewModuleDefinition, file);
211  return definition;
212}
213
214function omitViewFromClosureArguments(definitions: Closure[]) {
215  return definitions.map((d) => ({
216    ...d,
217    types: {
218      ...d.types,
219      parameters: d.types?.parameters?.filter((t, idx) => idx !== 0 && t.name !== 'view'),
220    },
221  }));
222}
223
224function parseModuleDefinition(
225  moduleDefinition: Structure[],
226  file: FileType
227): OutputModuleDefinition {
228  const parsedDefinition = {
229    name: findNamedDefinitionsOfType('Name', moduleDefinition, file)?.[0]?.name,
230    functions: findNamedDefinitionsOfType('Function', moduleDefinition, file),
231    asyncFunctions: findNamedDefinitionsOfType('AsyncFunction', moduleDefinition, file),
232    events: findGroupedDefinitionsOfType('Events', moduleDefinition, file),
233    properties: findNamedDefinitionsOfType('Property', moduleDefinition, file),
234    props: omitViewFromClosureArguments(findNamedDefinitionsOfType('Prop', moduleDefinition, file)),
235    view: findAndParseView(moduleDefinition, file),
236  };
237  return parsedDefinition;
238}
239
240function findModuleDefinitionsInFiles(files: string[]) {
241  const modules = [];
242  for (const path of files) {
243    const file = { path, content: fsNode.readFileSync(path, 'utf8') };
244    const definition = findModuleDefinitionInStructure(getStructureFromFile(file));
245    if (definition) {
246      modules.push(parseModuleDefinition(definition, file));
247    }
248  }
249  return modules;
250}
251
252export function getAllExpoModulesInWorkingDirectory() {
253  const files = globSync(pattern);
254  return findModuleDefinitionsInFiles(files);
255}
256