okai
Version:
AI-powered code generation tool for ServiceStack Apps. Generate TypeScript data models, C# APIs, migrations, and UI components from natural language prompts using LLMs.
607 lines • 23.3 kB
JavaScript
import { lastLeftPart, lastRightPart, leftPart, parseJsObject, rightPart, trimEnd, trimStart } from "./utils.js";
export class TypeScriptParser {
static CONFIG_TYPE_PATTERN = /export\s+type\s+Config\s*=\s*{([^}]+)}/;
static CONFIG_PROPERTY_PATTERN = /(\w+)\s*:\s*("[^"]*"|'[^']*')/g;
static DEFAULT_EXPORT_PATTERN = /export\s+default\s+({[^}]+})/;
static CLASS_PATTERN = /class\s+(\w+)(?:\s+extends\s+([\w\s<>,]+))?(?:\s+implements\s+([\w\s<>,]+))?\s*{/gm;
static INTERFACE_PATTERN = /interface\s+(\w+)(?:\s+extends\s+([\w\s<>,]+))?\s*{/gm;
static ENUM_PATTERN = /enum\s+(\w+)\s*{([^}]*)}/gm;
static PROPERTY_PATTERN = /(?:(?<modifier>private|public|protected|readonly)\s+)*(?<name>\w+)(?<optional>\?)?\s*:\s*(?<type>['"\w<>|[\]{}:,\s]+)?\s*;?/;
static ENUM_MEMBER_PATTERN = /(\w+)\s*(?:=\s*("[^"]*"|'[^']*'|\d+|[^,\n]+))?\s*/;
static ANNOTATION_PATTERN = /^\s*@([A-Za-z_][A-Za-z0-9_]*\.?[A-Za-z_]?[A-Za-z0-9_]*)/;
static ANNOTATION_COMMENT = /\)[\s]*\/\/.*$/;
static REFERENCE_PATTERN = /\/\/\/\s*<reference\s+path="([^"]+)"\s*\/>/g;
config;
defaultExport;
classes = [];
interfaces = [];
enums = [];
references = [];
getLineComment(line) {
if (line.match(TypeScriptParser.REFERENCE_PATTERN)) {
return undefined;
}
if (line.trim().startsWith('@')) {
if (TypeScriptParser.ANNOTATION_COMMENT.test(line)) {
return lastRightPart(line, '//').trim();
}
return undefined;
}
// Check for single line comment at end of line
const singleLineMatch = line.match(/.*?\/\/\s*(.+)$/);
if (singleLineMatch) {
return singleLineMatch[1].trim();
}
// Check for inline multi-line comment
const multiLineMatch = line.match(/.*?\/\*\s*(.+?)\s*\*\//);
if (multiLineMatch) {
return multiLineMatch[1].trim();
}
line = line.trim();
if (line.startsWith('/*') || line.startsWith('*')) {
return trimStart(trimStart(trimStart(line, '/'), '*'), '/').trim();
}
return undefined;
}
getPreviousLine(content, position) {
const beforePosition = content.substring(0, position);
const lineNumber = beforePosition.split('\n').length;
if (lineNumber > 0) {
const lines = content.split('\n');
const ret = lines[lineNumber - 2]; // -2 because array is 0-based and we want previous line
return ret;
}
return undefined;
}
parseConfigType(content) {
const match = content.match(TypeScriptParser.CONFIG_TYPE_PATTERN);
if (match) {
const configBody = match[1];
const config = {};
let propertyMatch;
while ((propertyMatch = TypeScriptParser.CONFIG_PROPERTY_PATTERN.exec(configBody))) {
const [, key, value] = propertyMatch;
config[key] = value.slice(1, -1); // Remove quotes
}
this.config = config;
}
}
parseDefaultExport(content) {
const match = content.match(TypeScriptParser.DEFAULT_EXPORT_PATTERN);
if (match) {
try {
const configStr = match[1];
const defaultExport = {};
const lines = configStr.split('\n');
for (const line of lines) {
if (line.includes(':')) {
const key = leftPart(line, ':').trim();
const val = trimEnd(rightPart(line, ':').trim(), ',');
defaultExport[key] = JSON.parse(val);
}
}
this.defaultExport = defaultExport;
}
catch (e) {
console.warn('Failed to parse default export config:', e);
}
}
}
parseMetadata(body, line) {
const annotations = [];
const comments = [];
const ANNOTATION = TypeScriptParser.ANNOTATION_PATTERN;
let previousLine = this.getPreviousLine(body, body.lastIndexOf(line));
while (previousLine && (!previousLine.match(TypeScriptParser.PROPERTY_PATTERN) || previousLine.match(ANNOTATION))) {
const annotation = previousLine.match(ANNOTATION) ? parseAnnotation(previousLine) : undefined;
if (annotation) {
annotations.push(annotation);
}
else {
const comment = this.isComment(previousLine) ? this.getLineComment(previousLine) : null;
if (comment) {
comments.unshift(comment);
}
}
previousLine = this.getPreviousLine(body, body.lastIndexOf(previousLine));
}
const lineComment = this.getLineComment(line);
if (lineComment) {
comments.push(lineComment);
}
else if (line.match(ANNOTATION)) {
const annotation = parseAnnotation(line);
if (annotation) {
annotations.push(annotation);
}
}
const ret = {
comment: comments.length ? comments.join('\n') : undefined,
annotations: annotations.length ? annotations : undefined,
};
return ret;
}
parseClassProperties(classBody) {
const props = [];
const lines = this.cleanBody(classBody).split('\n');
lines.forEach((line, index) => {
if (line.trim().startsWith('//'))
return;
if (line.match(TypeScriptParser.ANNOTATION_PATTERN))
return;
const match = line.match(TypeScriptParser.PROPERTY_PATTERN);
if (match?.groups) {
let type = match.groups.type?.trim();
const matchRecord = line.includes('{') ? line.match(/{\s*\[\s*\w+:\s*(\w+)\s*\]\s*:\s*(\w+)\s*;?\s*}/) : null;
if (matchRecord) {
// parse record: { [key: string]: string; }
const keyType = matchRecord[1];
const valType = leftPart(matchRecord[2], '|').trim();
type = `Record<${keyType},${valType}>`;
}
if (!type) {
console.log(`Failed to parse property type: ${line}`);
return;
}
const member = {
name: match.groups.name,
type,
};
if (match.groups.modifier)
member.modifier = match.groups.modifier;
if (match.groups.optional === '?')
member.optional = true;
// empty for properties with inline objects `salaryRange: {min:number, max:number};`
if (!member.type) {
return;
}
if (member.type.startsWith('"') || member.type.startsWith("'") || member.type.startsWith("`")) {
member.type = 'string';
}
else if (member.type.startsWith('{')) {
member.type = 'any';
}
else if (member.type.includes('|')) {
const { baseType, unionTypes } = extractTypeComponents(member.type);
member.type = baseType;
if (unionTypes.length) {
member.union = unionTypes;
if (unionTypes.includes('null') || unionTypes.includes('undefined')) {
member.optional = true;
}
}
}
let startIndex = index;
for (let i = index - 1; i >= 0; i--) {
const prevLine = lines[i].trim();
if (this.isComment(prevLine) || this.isAnnotation(prevLine)) {
startIndex = i;
}
else {
if (prevLine != '')
break;
}
}
const propMetadata = startIndex < index ? lines.slice(startIndex, index + 1).join('\n') : line;
// console.log('\npropMetadata <!--', propMetadata, '-->\n')
const { comment, annotations } = this.parseMetadata(propMetadata, line);
if (comment)
member.comment = comment;
if (annotations)
member.annotations = annotations;
// console.log('member', { comment, annotations, line })
props.push(member);
}
});
return props;
}
getBlockBody(content, startIndex) {
const bodyStartPos = content.indexOf('{', startIndex);
// console.log('bodyStartPos', `<|${content.substring(bodyStartPos, bodyStartPos + 20)}|>`)
// Find the end of the body
let depth = 0;
let bodyEndPos = bodyStartPos;
for (let i = bodyStartPos; i < content.length; i++) {
if (content[i] === '{')
depth++;
if (content[i] === '}')
depth--;
if (depth === 0) {
bodyEndPos = i + 1;
break;
}
}
let body = content.substring(bodyStartPos + 1, bodyEndPos - 1);
return body;
}
parseInterfaces(content) {
let match;
while ((match = TypeScriptParser.INTERFACE_PATTERN.exec(content))) {
const previousLine = this.getPreviousLine(content, match.index);
const body = this.getBlockBody(content, match.index);
const cls = {
name: match[1].trim(),
properties: this.parseClassProperties(body),
};
if (match[2]) {
cls.extends = match[2].trim();
}
if (previousLine) {
const { comment, annotations } = this.parseMetadata(content, previousLine);
if (comment)
cls.comment = comment;
if (annotations)
cls.annotations = annotations;
}
this.interfaces.push(cls);
}
}
parseClasses(content) {
let match;
while ((match = TypeScriptParser.CLASS_PATTERN.exec(content))) {
const previousLine = this.getPreviousLine(content, match.index);
const body = this.getBlockBody(content, match.index);
const cls = {
name: match[1],
properties: this.parseClassProperties(body),
};
const inherits = splitTypes(match[2]);
if (inherits.length) {
cls.extends = inherits[0];
if (inherits.length > 1) {
cls.implements = inherits.slice(1);
}
}
const impls = splitTypes(match[3]);
if (impls.length) {
if (!cls.implements)
cls.implements = [];
cls.implements.push(...impls);
}
if (previousLine) {
const { comment, annotations } = this.parseMetadata(content.substring(0, match.index), previousLine);
if (comment)
cls.comment = comment;
if (annotations)
cls.annotations = annotations;
}
// console.log('cls', cls.name, previousLine, cls.annotations)
this.classes.push(cls);
}
}
parseReferences(content) {
let match;
while ((match = TypeScriptParser.REFERENCE_PATTERN.exec(content))) {
this.references.push({ path: match[1] });
}
}
parseEnumMembers(enumBody) {
const members = [];
const lines = enumBody.split('\n')
.map(line => line.trim())
.filter(line => line && !line.match(/^\s*\/\//));
let prevIntValue = 0;
lines.forEach((line, index) => {
const match = line.match(TypeScriptParser.ENUM_MEMBER_PATTERN);
if (match) {
const [, name, value] = match;
const member = {
name: name.trim()
};
if (value) {
// Remove quotes if present
const cleanValue = value.trim().replace(/^["'`]|["'`]$/g, '');
member.value = isNaN(Number(cleanValue)) ? cleanValue : Number(cleanValue);
}
else {
member.value = prevIntValue;
}
if (typeof member.value === 'number') {
prevIntValue = member.value;
prevIntValue++;
}
const previousLine = this.getPreviousLine(enumBody, enumBody.indexOf(line));
if (previousLine && this.isComment(previousLine)) {
member.comment = this.getLineComment(previousLine);
}
const lineComment = this.getLineComment(line);
if (lineComment) {
member.comment = member.comment ? member.comment + `\n${lineComment}` : lineComment;
}
members.push(member);
}
});
return members;
}
isAnnotation(s) {
if (!s)
return false;
s = s.trim();
return s.startsWith('@');
}
isComment(s) {
if (!s)
return false;
s = s.trim();
return s.startsWith('//') || s.startsWith('/*') || s.startsWith('*');
}
parseEnums(content) {
let match;
while ((match = TypeScriptParser.ENUM_PATTERN.exec(content))) {
const previousLine = this.getPreviousLine(content, match.index);
const { comment, annotations } = previousLine
? this.parseMetadata(content.substring(0, match.index), previousLine)
: { comment: undefined, annotations: undefined };
this.enums.push({
name: match[1],
comment: previousLine ? comment : undefined,
members: this.parseEnumMembers(match[2])
});
}
}
cleanBody(body) {
return body
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('\n');
}
parse(sourceCode) {
this.config = undefined;
this.defaultExport = undefined;
this.classes = [];
this.interfaces = [];
this.enums = [];
let src = sourceCode;
src = convertJsDocComments(src);
src = removeMultilineComments(src);
// const src = sourceCode
this.parseConfigType(src);
this.parseDefaultExport(src);
this.parseReferences(src);
this.parseInterfaces(src);
this.parseClasses(src);
this.parseEnums(src);
return {
config: this.config,
defaultExport: this.defaultExport,
references: this.references,
classes: this.classes,
interfaces: this.interfaces,
enums: this.enums
};
}
}
export function parseAnnotation(annotation) {
const regex = TypeScriptParser.ANNOTATION_PATTERN;
// search for // after closing parenthesis and remove it
if (TypeScriptParser.ANNOTATION_COMMENT.test(annotation)) {
annotation = lastLeftPart(annotation, '//');
}
const match = annotation.match(regex);
if (!match)
return null;
const [, name] = match;
// Extract parameters if they exist
const paramsStr = annotation.includes('(') && annotation.includes(')')
? lastLeftPart(rightPart(annotation, '('), ')')
: '';
try {
// Handle multiple arguments by splitting on commas outside quotes/braces
const rawArgs = splitArgs(paramsStr);
// Parse each argument
const parsedArgs = rawArgs.map(arg => {
if (arg.startsWith('{')) {
return parseJsObject(arg);
}
else if (arg.startsWith('"') || arg.startsWith("'") || arg.startsWith("`")) {
// Parse strings
return arg.slice(1, -1);
}
else if (!isNaN(parseInt(arg))) {
// Parse numbers
return Number(arg);
}
return arg;
});
const lastArg = parsedArgs[parsedArgs.length - 1];
const args = typeof lastArg === 'object'
? lastArg
: undefined;
const constructorArgs = args ? parsedArgs.slice(0, -1) : parsedArgs;
const to = { name };
if (constructorArgs.length)
to.constructorArgs = constructorArgs;
if (args)
to.args = args;
return to;
}
catch (e) {
console.log('Failed to parse annotation:', e);
return null;
}
}
// Helper to split args while respecting objects/strings
function splitArgs(str) {
const args = [];
let current = '';
let depth = 0;
let inQuotes = false;
let quoteChar = '';
for (let char of str) {
if ((char === '"' || char === "'") && !inQuotes) {
inQuotes = true;
quoteChar = char;
}
else if (char === quoteChar && inQuotes) {
inQuotes = false;
}
else if (char === '{')
depth++;
else if (char === '}')
depth--;
else if (char === ',' && depth === 0 && !inQuotes) {
args.push(current.trim());
current = '';
continue;
}
current += char;
}
if (current.trim())
args.push(current.trim());
return args;
}
export function splitTypes(input) {
const result = [];
let currentToken = '';
let angleBracketCount = 0;
// Trim and handle empty input
input = input?.trim();
if (!input)
return [];
// Process each character
for (let char of input) {
if (char === '<') {
angleBracketCount++;
currentToken += char;
}
else if (char === '>') {
angleBracketCount--;
currentToken += char;
}
else if (char === ',' && angleBracketCount === 0) {
// Only split on commas when we're not inside angle brackets
if (currentToken.trim()) {
result.push(currentToken.trim());
}
currentToken = '';
}
else {
currentToken += char;
}
}
// Add the last token if it exists
if (currentToken.trim()) {
result.push(currentToken.trim());
}
return result;
}
export function extractTypeComponents(typeStr) {
const types = splitUnionTypes(typeStr);
const cleanTypes = types.map(t => t.trim());
// Handle generic types
if (hasGenericType(typeStr)) {
const { containerType, typeParams } = parseGenericType(typeStr);
const extractedParams = typeParams.map(param => extractTypeComponents(param));
return {
baseType: `${containerType}<${extractedParams[0].baseType}>`,
unionTypes: extractedParams.flatMap(p => p.unionTypes)
};
}
// Find primary type (non-null, non-undefined)
const baseType = cleanTypes.find(t => !isNullableType(t)) || cleanTypes[0];
const unionTypes = cleanTypes.filter(t => t !== baseType);
return { baseType, unionTypes };
}
function splitUnionTypes(typeStr) {
let depth = 0;
let current = '';
const result = [];
for (const char of typeStr) {
if (char === '<')
depth++;
if (char === '>')
depth--;
if (char === '|' && depth === 0) {
result.push(current.trim());
current = '';
}
else {
current += char;
}
}
if (current)
result.push(current.trim());
return result;
}
function hasGenericType(typeStr) {
return typeStr.includes('<') && typeStr.includes('>');
}
function parseGenericType(typeStr) {
const match = typeStr.match(/^([^<]+)<(.+)>$/);
if (!match)
throw new Error('Invalid generic type format');
const containerType = match[1];
const paramStr = match[2];
const typeParams = splitUnionTypes(paramStr);
return { containerType, typeParams };
}
function isNullableType(type) {
return type === 'null' || type === 'undefined';
}
// Removes /*: Merge with User DTO... */ comments from the source code
export function removeMultilineComments(src) {
let result = '';
let inComment = false;
let i = 0;
while (i < src.length) {
// Check for comment start
if (src[i] === '/' && src[i + 1] === '*' && src[i + 2] === ':') {
inComment = true;
i += 2;
continue;
}
// Check for comment end
if (inComment && (src[i] === '*' && src[i + 1] === '/')) {
inComment = false;
i += 2;
continue;
}
// Only append characters when not in a comment
if (!inComment) {
result += src[i];
}
i++;
}
return result;
}
export function convertJsDocComments(src) {
if (!src || !src.includes('/*')) {
return src;
}
let result = src;
// Convert multi-line JSDoc block comments (/**...*/)
result = result.replace(/\/\*\*[\s\n]+(\s*\*[\s\n]+.*?[\s\n]+)*?\s*\*\//gs, (match, _, offset) => {
// Find the indentation level of the line that contains or follows the comment
const lines = src.substring(0, offset + match.length).split('\n');
const commentEndLineIndex = lines.length - 1;
const nextLineIndex = commentEndLineIndex + 1;
// Try to find the indentation from the line following the comment
let indent = '';
if (nextLineIndex < src.split('\n').length) {
const nextLine = src.split('\n')[nextLineIndex];
const nextLineIndent = nextLine.match(/^(\s*)/)?.[1] || '';
indent = nextLineIndent;
}
else {
// Fallback to the first non-empty line in the comment
const firstLine = match.split('\n').find(line => line.trim() !== '');
indent = firstLine?.match(/^(\s*)/)?.[1] || '';
}
// Process each line of the comment, ensuring consistent indentation for all lines
const commentLines = match.split('\n')
.slice(1, -1) // Remove first (/**) and last (*/) lines
.map(line => {
// Remove * prefix from each line and convert to // comment
const content = line.replace(/^\s*\*\s?/, '').trimEnd();
return content.length > 0 ? `${indent}// ${content}` : `${indent}//`;
});
return commentLines.join('\n');
});
// Convert single-line JSDoc comments (/** ... */)
result = result.replace(/\/\*\*\s+(.*?)\s+\*\//g, '// $1');
// Convert inline JSDoc comments after statements
result = result.replace(/(.+?)\s+\/\*\*\s+(.*?)\s+\*\//g, '$1 // $2');
return result;
}
//# sourceMappingURL=ts-parser.js.map