import { ArrayShape, BooleanShape, NumberShape, ObjectShape, Plugin, Shape, UndefinedShape, SourceFileShape, IdentifierShape, ts, isUndefined, getValueDeclaration, VariableStatementShape, ElementAccessExpressionShape, TemplateExpressionShape, TemplateSpanShape, TaggedTemplateExpressionShape, StringLiteralShape, PropertyAccessShape, NOW_FILE_EXTENSION, DeletedShape, removeReferences, remove, isFluentFile, path as pathModule, Record as DBRecord, asDataHelper, isValidNode, NowConfig, } from '@servicenow/sdk-build-core' import { isDataHelper } from './data-plugin' import { getCallExpressionName } from './utils' import { NowIncludePlugin, NowIncludeShape } from './now-include-plugin' export const BasicSyntaxPlugin = Plugin.create({ name: 'BasicSyntaxPlugin', noTelemetry: true, files: [ { entryPoint: true, matcher: RegExp(`${NOW_FILE_EXTENSION}$`), }, ], shapes: [ { shape: SourceFileShape, getTarget(shape, { compiler, project }) { const source = shape.getOriginalSource() return { success: true, value: ts.Node.isNode(source) ? source : compiler.getOrCreateSourceFile(project.resolvePath(shape.getPath())), } }, commit(shape, _target, { project }) { const file = project.getFileIfExists(shape.getPath()) if (file) { file.setContent(shape.getContent()) } else { project.addFile({ path: shape.getPath(), content: shape.getContent() }) } return { success: true } }, }, { shape: Shape, getTarget(shape, { compiler, project, config }) { const source = shape.getOriginalSource() if (isValidNode(source)) { return { success: true, value: source } } // Avoid creating empty files with undefined shapes or deleted records if (shape.isNoOp() || shape.isUndefined() || (shape.isRecord() && shape.isDeleted())) { return { success: false } } const { generatedDir, taxonomy } = config const tableName = shape.getSource() instanceof DBRecord ? (shape.getSource() as DBRecord).getTable() : undefined const hostedDir = NowConfig.getHostedDirectory(config, source.path) const conditionalDir = NowConfig.getConditionalDirectory(config, source.path) const taxonomySubdir = tableName && taxonomy.mapping[tableName] ? taxonomy.mapping[tableName] : '' // The hosted/conditional plugin prefix (e.g. `if/com.`) is orthogonal to // the taxonomy folder (e.g. `data/table`). Compose them so records keep their // taxonomy organization even when they live under a conditional/hosted plugin // directory, instead of the plugin prefix overriding the taxonomy (DEF0844422). const prefixDir = pathModule.join(hostedDir ?? '', conditionalDir ?? '') const dir = pathModule.join(generatedDir, prefixDir, taxonomySubdir) let name = pathModule.basename(source.path).replace(RegExp(`${pathModule.extname(source.path)}$`), '') if (!name) { const id = shape.getSource() instanceof DBRecord ? (shape.getSource() as DBRecord).getId().getValue() : undefined name = `${tableName}_${id}` } if (project.isTypesGenerationMode()) { // In types generation mode, use the exported variable name as the file name // Scope directory is already set via per-scope Project rootDir if (shape.is(VariableStatementShape) && shape.isExported()) { name = shape.getVariableName().getName() } } const file = compiler.getOrCreateSourceFile( project.resolvePath(project.getRootDir(), dir, `${name}.now.ts`) ) const statement = project .addFile(file.getFilePath(), { resolveDependencies: false }) .addStatement(shape.getCode()) return { success: true, value: statement.isKind(ts.SyntaxKind.ExpressionStatement) ? statement.getExpression() : statement, } }, }, { shape: DeletedShape, commit(_, target) { removeReferences(target) remove(target) return { success: true } }, }, { shape: UndefinedShape, commit(_, target) { if (!isUndefined(target)) { target.replaceWithText('undefined') } return { success: true } }, }, { shape: TemplateExpressionShape, async commit(shape, target, { commit }) { if ( !ts.Node.isTemplateExpression(target) && !ts.Node.isTaggedTemplateExpression(target) && !ts.Node.isNoSubstitutionTemplateLiteral(target) ) { target.replaceWithText(shape.getCode()) return { success: true } } const template = ts.Node.isTaggedTemplateExpression(target) ? target.removeTag() : target const targetSpans = ts.Node.isTemplateExpression(template) ? template.getTemplateSpans() : [] const shapeSpans = shape.getSpans() if (shapeSpans.length !== targetSpans.length) { template.replaceWithText(shape.getCode()) return { success: true } } for (const [i, targetSpan] of targetSpans.entries()) { const shapeSpan = shapeSpans[i] if (!shapeSpan) { throw new Error(`Expected shape to have a span at index ${i}`) } await commit(shapeSpan, targetSpan) } return { success: true } }, }, { shape: TemplateSpanShape, async commit(shape, target, { commit }) { if (!ts.Node.isTemplateSpan(target)) { target.replaceWithText(shape.getCode()) return { success: true } } const literal = target.getLiteral() if (shape.getLiteralText() !== literal.getLiteralText()) { if (literal.isKind(ts.SyntaxKind.TemplateMiddle)) { literal.replaceWithText(`}${shape.getLiteralText()}\${`) } else if (literal.isKind(ts.SyntaxKind.TemplateTail)) { literal.replaceWithText(`}${shape.getLiteralText()}\``) } } await commit(shape.getExpression(), target.getExpression()) return { success: true } }, }, { shape: StringLiteralShape, async commit(shape, target, { commit }) { const coerced = (() => { try { if (ts.Node.isTrueLiteral(target) || ts.Node.isFalseLiteral(target)) { return shape.toBoolean() } else if (ts.Node.isNumericLiteral(target)) { const number = shape.toNumber() // Turn the number back into a string and compare with the original string to avoid // lossy conversions due to floating point precision or other factors return number.getValue().toString() === shape.getValue() ? number : undefined } else if (isDataHelper(target)) { if (!ts.Node.isCallExpression(target)) { return undefined } const helperName = getCallExpressionName(target) const timeZone = target .getArguments()[1] ?.asKind(ts.SyntaxKind.StringLiteral) ?.getLiteralValue() return asDataHelper(helperName, shape, timeZone) } else { return undefined } } catch { return undefined } })() if (coerced) { await commit(coerced, target) return { success: true } } if (ts.Node.isTaggedTemplateExpression(target)) { // Preserve tags when updating target.getTemplate().replaceWithText( new TemplateExpressionShape({ source: shape, literalText: shape, }).getCode() ) } else { target.replaceWithText(shape.getCode()) } return { success: true } }, }, { shape: NumberShape, commit(shape, target) { if (ts.Node.isNumericLiteral(target) && target.getLiteralValue() === shape.getValue()) { return { success: true } } target.replaceWithText(shape.getValue().toString()) return { success: true } }, }, { shape: BooleanShape, commit(shape, target) { if ( (ts.Node.isTrueLiteral(target) || ts.Node.isFalseLiteral(target)) && target.getLiteralValue() === shape.getValue() ) { return { success: true } } target.replaceWithText(shape.getValue().toString()) return { success: true } }, }, { shape: ArrayShape, async commit(shape, target, { commit }) { if (!ts.Node.isArrayLiteralExpression(target)) { target.replaceWithText(shape.getCode()) return { success: true } } // Trim any excess elements const targetElements = target.getElements() const shapeElements = shape.getElements(false) const excess = targetElements.length - shapeElements.length for (let i = 1; i <= excess; i++) { target.removeElement(targetElements.length - i) } // Commit existing elements in the trimmed array for (const [i, targetElement] of target.getElements().entries()) { await commit(shape.getElement(i, false), targetElement) } // Add new elements all at once, and we're done const elementsToAdd = shapeElements.slice(target.getElements().length).map((e) => e.getCode()) if (elementsToAdd.length > 0) { target.addElements(elementsToAdd) } return { success: true } }, }, { shape: ObjectShape, async commit(shape, target, { commit }) { if (!ts.Node.isObjectLiteralExpression(target)) { target.replaceWithText(shape.getCode()) return { success: true } } const existingNames: Map = target .getProperties() .filter((p) => p.isKind(ts.SyntaxKind.PropertyAssignment)) .reduce( (map, p) => map.set(getName(p), p.getInitializerOrThrow()), new Map() ) const propsToRetain = new Set(['$meta']) const propsToAdd: ts.PropertyAssignmentStructure[] = [] const nowIncludePropsToCommit = new Set() for (const [name, value] of shape.entries({ resolve: false })) { const existingPropKey = [...shape.getAliases(name), name].find((aliasedKey) => existingNames.has(aliasedKey) ) if (existingPropKey !== undefined) { if (existingPropKey !== name) { target .getPropertyOrThrow(existingPropKey) .asKindOrThrow(ts.SyntaxKind.PropertyAssignment) .getNameNode() .replaceWithText(name) existingNames.set(name, existingNames.get(existingPropKey)!) existingNames.delete(existingPropKey) } await commit(value, existingNames.get(name) as ts.Expression) } else if (!value.equals(shape.getDefault(name))) { if (value.is(NowIncludeShape)) { nowIncludePropsToCommit.add(name) } propsToAdd.push({ name: ObjectShape.quotePropertyNameIfNeeded(name), initializer: value.getCode(), kind: ts.StructureKind.PropertyAssignment, }) } propsToRetain.add(name) } // Add all missing props at once to improve performance if (propsToAdd.length > 0) { target.addPropertyAssignments(propsToAdd) } // commit Now.include props to generate the include file. for (const prop of nowIncludePropsToCommit) { const includeInitializer = target .getPropertyOrThrow(prop) .asKindOrThrow(ts.SyntaxKind.PropertyAssignment) .getInitializerOrThrow() await commit(shape.get(prop, false), includeInitializer, NowIncludePlugin) } const propsToRemove = [...existingNames.keys()].filter((key) => !propsToRetain.has(key)) propsToRemove.forEach((prop) => { existingNames.get(prop)?.getParent()?.asKindOrThrow(ts.SyntaxKind.PropertyAssignment).remove() }) return { success: true } }, }, { shape: IdentifierShape, commit(shape, target) { if (ts.Node.isIdentifier(target) && target.getText() === shape.getName()) { return { success: true } } target.replaceWithText(shape.getName()) return { success: true } }, }, { shape: VariableStatementShape, async commit(shape, target, { commit }) { // If target is ExpressionStatement, replace it directly if (ts.Node.isExpressionStatement(target)) { target.replaceWithText(shape.getCode()) return { success: true } } // If target is CallExpression, check parent type if (ts.Node.isCallExpression(target)) { const expressionStatement = target.getParentIfKind(ts.SyntaxKind.ExpressionStatement) if (expressionStatement) { expressionStatement.replaceWithText(shape.getCode()) return { success: true } } } // Get VariableStatement for in-place updates (either from CallExpression ancestor or target itself) const variableStatement = ts.Node.isVariableStatement(target) ? target : target.getFirstAncestorByKindOrThrow(ts.SyntaxKind.VariableStatement) // Update export modifier if needed if (variableStatement.isExported() !== shape.isExported()) { variableStatement.setIsExported(shape.isExported()) } // Get the variable declaration const [declaration, otherDeclaration] = variableStatement.getDeclarations() if (!declaration) { throw new Error(`Variable statement is missing a declaration: ${variableStatement.getFullText()}`) } // Remove duplicate declarations if (otherDeclaration) { removeReferences(otherDeclaration) remove(otherDeclaration) } // Update variable name if changed const variableName = shape.getVariableName() if (declaration.getName() !== variableName.getName()) { await commit(variableName, declaration.getNameNode()) } // Update initializer await commit(shape.getInitializer(), declaration.getInitializerOrThrow()) return { success: true } }, }, ], nodes: [ { node: 'SourceFile', async toShape(file, { diagnostics, transform }) { if (isFluentFile(file)) { const syntaxList = file.getChildSyntaxList() if (!syntaxList) { diagnostics.error(file, 'Fluent source file contains no meaningful content.') return { success: false } } for (const child of syntaxList.getChildren()) { if (ts.Node.isCommentNode(child)) { continue } const result = await transform.toShape(child) if (!result.success) { diagnostics.error(child, 'Unsupported statement in Fluent source file.') return { success: false } } } } return { success: true, value: new SourceFileShape({ source: file, path: file.getFilePath(), content: file.getFullText() }), } }, }, { node: 'StringLiteral', toShape(node) { return { success: true, value: new StringLiteralShape({ source: node, literalText: node.getLiteralValue() }), } }, }, { node: 'NoSubstitutionTemplateLiteral', toShape(node) { return { success: true, value: new TemplateExpressionShape({ source: node, literalText: node.getLiteralValue() }), } }, }, { node: 'NumericLiteral', toShape(node) { return { success: true, value: new NumberShape({ source: node, value: node.getLiteralValue() }), } }, }, { node: 'NullKeyword', toShape(node) { return { success: true, value: new UndefinedShape({ source: node }), } }, }, { node: 'TrueKeyword', toShape(node) { return { success: true, value: new BooleanShape({ source: node, value: true }), } }, }, { node: 'FalseKeyword', toShape(node) { return { success: true, value: new BooleanShape({ source: node, value: false }), } }, }, { node: 'AsExpression', toShape(node, { transform }) { return transform.toShape(node.getExpression()) }, }, { node: 'ExportAssignment', toShape(node, { transform }) { return transform.toShape(node.getExpression()) }, }, { node: 'ComputedPropertyName', toShape(node, { transform }) { return transform.toShape(node.getExpression()) }, }, { node: 'PropertyAccessExpression', async toShape(node, { transform, diagnostics }) { const lastElementNode = node.getNameNode() const lastElement = await transform.toShape(lastElementNode) if (!lastElement.success || !lastElement.value.isIdentifier()) { diagnostics.error(lastElementNode, 'Last element of property access expression is unsupported') return { success: false } } const expressionNode = node.getExpression() const expression = await transform.toShape(expressionNode) if (!expression.success || !expression.value.is([IdentifierShape, PropertyAccessShape])) { diagnostics.error(expressionNode, 'Left side of property access expression is unsupported') return { success: false } } const elements = expression.value.isIdentifier() ? ([expression.value, lastElement.value] as const) : ([...expression.value.getElements(), lastElement.value] as const) return { success: true, value: new PropertyAccessShape({ source: node, elements }), } }, }, { node: 'ExpressionStatement', toShape(node, { transform }) { return transform.toShape(node.getExpression()) }, }, { node: 'ParenthesizedExpression', toShape(node, { transform }) { return transform.toShape(node.getExpression()) }, }, { node: 'Identifier', async toShape(node, { transform }) { if (isUndefined(node)) { return { success: true, value: new UndefinedShape({ source: node }), } } const name = node.getSymbol()?.getName() ?? node.getText() const valueDeclaration = getValueDeclaration(node) const valueResult = valueDeclaration && (await transform.toShape(valueDeclaration)) return { success: true, value: valueResult?.success ? new IdentifierShape({ source: node, name, value: valueResult.value }) : new IdentifierShape({ source: node, name }), } }, }, { node: 'Parameter', toShape(node) { return { success: true, value: new IdentifierShape({ source: node, name: node.getName(), }), } }, }, { node: 'VariableStatement', async toShape(node, { transform, diagnostics }) { const kind = node.getDeclarationKind() if (kind !== ts.VariableDeclarationKind.Const) { diagnostics.error( node.getDeclarationKindKeywords()[0] ?? node, `Declaration kind "${kind}" is not supported. Only const variables are supported.` ) return { success: false } } const defaultKeyword = node.getDefaultKeyword() if (defaultKeyword) { diagnostics.error(defaultKeyword, 'Default exports are not supported') return { success: false } } const [declaration, otherDeclaration] = node.getDeclarations() if (!declaration) { diagnostics.error(node, 'Missing declaration in variable statement') return { success: false } } if (otherDeclaration) { diagnostics.error( otherDeclaration, 'Additional declarations in variable statements are not supported' ) return { success: false } } const initializer = declaration.getInitializerOrThrow() const initializerResult = await transform.toShape(initializer) if (!initializerResult.success) { diagnostics.error(initializer, 'Unsupported variable initializer') return { success: false } } return { success: true, value: new VariableStatementShape({ source: node, variableName: new IdentifierShape({ source: node, name: declaration.getName() }), initializer: initializerResult.value, isExported: node.isExported(), }), } }, }, { node: 'VariableDeclaration', toShape(node, { transform }) { const initializer = node.getInitializer() return initializer ? transform.toShape(initializer) : { success: true, value: new UndefinedShape({ source: node }), } }, }, { node: 'ArrayLiteralExpression', async toShape(node, { transform }) { const elements: Shape[] = [] for (const element of node.getElements()) { const result = await transform.toShape(element) if (!result.success) { return { success: false } } elements.push(result.value) } return { success: true, value: new ArrayShape({ source: node, elements }), } }, }, { node: 'PropertyAssignment', toShape(node, { transform, diagnostics }) { const initializer = node.getInitializer() if (!initializer) { diagnostics.error(node, 'Property must have an initializer') return { success: false } } return transform.toShape(initializer) }, }, { node: 'ObjectLiteralExpression', async toShape(node, { transform, diagnostics }) { const properties: Record = {} for (const property of node.getProperties()) { if (!ts.Node.isPropertyAssignment(property)) { diagnostics.error(property, 'Only property assignments are allowed') continue } const result = await transform.toShape(property) if (!result.success) { diagnostics.error(property, 'Failed to parse property') return { success: false } } const nameNode = property.getNameNode() const name = ts.Node.isStringLiteral(nameNode) ? nameNode.getLiteralValue() : property.getName() properties[name] = result.value } return { success: true, value: new ObjectShape({ source: node, properties }), } }, }, { node: 'TemplateSpan', async toShape(span, { transform, diagnostics }) { const expression = await transform.toShape(span.getExpression()) if (!expression.success) { diagnostics.error(span, 'Unsupported expression in template') return { success: false } } return { success: true, value: new TemplateSpanShape({ source: span, expression: expression.value, literalText: span.getLiteral().getLiteralText(), }), } }, }, { node: 'TemplateExpression', async toShape(node, { transform, diagnostics }) { const spans: TemplateSpanShape[] = [] for (const span of node.getTemplateSpans()) { const result = await transform.toShape(span) if (!result.success || !result.value.is(TemplateSpanShape)) { diagnostics.error(span, 'Unsupported span in template expression') return { success: false } } spans.push(result.value) } return { success: true, value: new TemplateExpressionShape({ source: node, literalText: node.getHead().getLiteralText(), spans, }), } }, }, { node: 'TaggedTemplateExpression', async toShape(node, { transform, diagnostics }) { const template = node.getTemplate() const templateResult = await transform.toShape(template) if (!templateResult.success || !templateResult.value.is(TemplateExpressionShape)) { diagnostics.error(template, 'Unsupported template in tagged template expression') return { success: false } } return { success: true, value: new TaggedTemplateExpressionShape({ source: node, tag: node.getTag().getText(), template: templateResult.value, }), } }, }, { node: 'PrefixUnaryExpression', toShape(node, { diagnostics }) { if ( !ts.Node.isNumericLiteral(node.getOperand()) || node.getOperatorToken() !== ts.SyntaxKind.MinusToken ) { diagnostics.error(node, 'Unsupported prefix unary expression') return { success: false } } return { success: true, value: new NumberShape({ source: node, value: -node.getOperand().asKindOrThrow(ts.SyntaxKind.NumericLiteral).getLiteralValue(), }), } }, }, { node: 'ElementAccessExpression', async toShape(node, { transform, diagnostics }) { const argumentExpression = node.getArgumentExpression() if (!argumentExpression) { diagnostics.error(node, 'Element access expression must have an argument') return { success: false } } const argumentShape = await transform.toShape(argumentExpression) if (!argumentShape.success) { diagnostics.error(node, 'Unsupported element access expression argument') return { success: false } } return { success: true, value: new ElementAccessExpressionShape({ source: node, callee: node.getExpression().getText(), arg: argumentShape.value, }), } }, }, { node: 'ImportDeclaration', toShape(node) { // TODO: This is just to prevent diagnostic errors. We should return a meaningful shape here. return { success: true, value: Shape.noOp(node), } }, }, { node: 'ExportDeclaration', toShape(node) { // TODO: This is just to prevent diagnostic errors. We should return a meaningful shape here. return { success: true, value: Shape.noOp(node), } }, }, ], }) function getName(node: ts.PropertyAssignment): string { const nameNode = node.getNameNode() return ts.Node.isStringLiteral(nameNode) || ts.Node.isNoSubstitutionTemplateLiteral(nameNode) ? nameNode.getLiteralValue() : nameNode.getText() }