diff --git a/Composer/packages/client/src/store/parsers/workers/indexer.worker.ts b/Composer/packages/client/src/store/parsers/workers/indexer.worker.ts index 0e9402088d..419021c48f 100644 --- a/Composer/packages/client/src/store/parsers/workers/indexer.worker.ts +++ b/Composer/packages/client/src/store/parsers/workers/indexer.worker.ts @@ -6,11 +6,11 @@ const ctx: Worker = self as any; ctx.onmessage = function (msg) { const { id, payload } = msg.data; - const { files, botName, schemas, locale } = payload; + const { files, botName, locale } = payload; const { index } = indexer; try { - const { dialogs, luFiles, lgFiles } = index(files, botName, schemas, locale); + const { dialogs, luFiles, lgFiles } = index(files, botName, locale); ctx.postMessage({ id, payload: { dialogs, luFiles, lgFiles } }); } catch (error) { ctx.postMessage({ id, error }); diff --git a/Composer/packages/client/src/store/reducer/index.ts b/Composer/packages/client/src/store/reducer/index.ts index 8e50f20acc..9ba0eb1dc1 100644 --- a/Composer/packages/client/src/store/reducer/index.ts +++ b/Composer/packages/client/src/store/reducer/index.ts @@ -5,7 +5,7 @@ import get from 'lodash/get'; import set from 'lodash/set'; import has from 'lodash/has'; import merge from 'lodash/merge'; -import { indexer, dialogIndexer, lgIndexer, luIndexer, autofixReferInDialog } from '@bfc/indexers'; +import { indexer, dialogIndexer, lgIndexer, luIndexer, autofixReferInDialog, validateDialog } from '@bfc/indexers'; import { SensitiveProperties, LuFile, @@ -84,9 +84,12 @@ const initLuFilesStatus = (projectId: string, luFiles: LuFile[], dialogs: Dialog const getProjectSuccess: ReducerFunc = (state, { response }) => { const { files, botName, botEnvironment, location, schemas, settings, id, locale, diagnostics } = response.data; schemas.sdk.content = processSchema(id, schemas.sdk.content); - const { dialogs, luFiles, lgFiles, skillManifestFiles } = indexer.index(files, botName, schemas.sdk.content, locale); + const { dialogs, luFiles, lgFiles, skillManifestFiles } = indexer.index(files, botName, locale); state.projectId = id; - state.dialogs = dialogs; + state.dialogs = dialogs.map((dialog) => { + dialog.diagnostics = validateDialog(dialog, schemas.sdk.content, lgFiles, luFiles); + return dialog; + }); state.botEnvironment = botEnvironment || state.botEnvironment; state.botName = botName; state.botStatus = location === state.location ? state.botStatus : BotStatus.unConnected; @@ -166,8 +169,7 @@ const createLgFile: ReducerFunc = (state, { id, content }) => { const { parse } = lgIndexer; const lgImportresolver = importResolverGenerator(state.lgFiles, '.lg'); - const { templates, diagnostics } = parse(content, id, lgImportresolver); - const lgFile = { id, templates, diagnostics, content }; + const lgFile = { id, content, ...parse(content, id, lgImportresolver) }; state.lgFiles.push(lgFile); return state; }; @@ -228,7 +230,12 @@ const updateLuTemplate: ReducerFunc = (state, luFile: LuFile) => { const updateDialog: ReducerFunc = (state, { id, content }) => { state.dialogs = state.dialogs.map((dialog) => { if (dialog.id === id) { - return { ...dialog, ...dialogIndexer.parse(dialog.id, content, state.schemas.sdk.content) }; + dialog = { + ...dialog, + ...dialogIndexer.parse(dialog.id, content), + }; + dialog.diagnostics = validateDialog(dialog, state.schemas.sdk.content, state.lgFiles, state.luFiles); + return dialog; } return dialog; }); @@ -261,8 +268,9 @@ const createDialog: ReducerFunc = (state, { id, content }) => { const dialog = { isRoot: false, displayName: id, - ...dialogIndexer.parse(id, fixedContent, state.schemas.sdk.content), + ...dialogIndexer.parse(id, fixedContent), }; + dialog.diagnostics = validateDialog(dialog, state.schemas.sdk.content, state.lgFiles, state.luFiles); state.dialogs.push(dialog); state = createLgFile(state, { id, content: '' }); state = createLuFile(state, { id, content: '' }); diff --git a/Composer/packages/lib/indexers/__tests__/dialogUtils/dialogChecker.test.ts b/Composer/packages/lib/indexers/__tests__/dialogUtils/dialogChecker.test.ts index 89fb34f690..a0ae8a7bfa 100644 --- a/Composer/packages/lib/indexers/__tests__/dialogUtils/dialogChecker.test.ts +++ b/Composer/packages/lib/indexers/__tests__/dialogUtils/dialogChecker.test.ts @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { createPath } from './../../src/dialogUtils/dialogChecker'; +import { createPath } from '../../src/validations/expressionValidation/utils'; describe('create right diagnostic path', () => { it('should check if the diagnostics have errors', () => { diff --git a/Composer/packages/lib/indexers/__tests__/lgUtil.test.ts b/Composer/packages/lib/indexers/__tests__/lgUtil.test.ts index dfdbfffa6a..be1a99763b 100644 --- a/Composer/packages/lib/indexers/__tests__/lgUtil.test.ts +++ b/Composer/packages/lib/indexers/__tests__/lgUtil.test.ts @@ -3,7 +3,7 @@ import { Templates } from 'botbuilder-lg'; -import { updateTemplate, addTemplate, removeTemplate } from '../src/utils/lgUtil'; +import { updateTemplate, addTemplate, removeTemplate, extractOptionByKey } from '../src/utils/lgUtil'; describe('update lg template', () => { it('should update lg template', () => { @@ -69,3 +69,15 @@ describe('add lg template', () => { expect(templates[0].name).toEqual('Exit'); }); }); + +describe('extract option by key', () => { + it('should extract optin', () => { + const options = ['@strict = false', '@Namespace = foo', '@Exports = bar, cool']; + const namespace = extractOptionByKey('@namespace', options); + expect(namespace).toBe('foo'); + const namespace2 = extractOptionByKey('@wrong', options); + expect(namespace2).toBe(''); + const strict = extractOptionByKey('@strict', options); + expect(strict).toBe('false'); + }); +}); diff --git a/Composer/packages/lib/indexers/__tests__/validations/expressionValidation.test.ts b/Composer/packages/lib/indexers/__tests__/validations/expressionValidation.test.ts new file mode 100644 index 0000000000..b064d76fc4 --- /dev/null +++ b/Composer/packages/lib/indexers/__tests__/validations/expressionValidation.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { LgFile } from '@bfc/shared'; + +import { validate } from '../../src/validations/expressionValidation/validation'; + +import { searchLgCustomFunction } from './../../src/validations/expressionValidation/index'; + +describe('search lg custom function', () => { + it('should return custom functions with namespace', () => { + const lgFiles = [{ options: ['@strict = false', '@Namespace = foo', '@Exports = bar, cool'], id: 'test.en-us' }]; + const result = searchLgCustomFunction(lgFiles as LgFile[]); + expect(result.length).toEqual(2); + expect(result[0]).toEqual('foo.bar'); + expect(result[1]).toEqual('foo.cool'); + }); + + it('should return custom functions with namespace', () => { + const lgFiles = [{ options: ['@strict = false', '@Exports = bar, cool'], id: 'test.en-us' }]; + const result = searchLgCustomFunction(lgFiles as LgFile[]); + expect(result.length).toEqual(2); + expect(result[0]).toEqual('test.en-us.bar'); + expect(result[1]).toEqual('test.en-us.cool'); + }); +}); + +describe('validate expression', () => { + it('if string expression do nothing', () => { + const expression = { value: 'hello', required: false, path: 'test', types: ['string'] }; + const result = validate(expression, []); + expect(result).toBeNull(); + }); + + it('if start with =, but type is not match', () => { + const expression = { value: '=13', required: false, path: 'test', types: ['string'] }; + const result = validate(expression, []); + expect(result?.message).toBe('the expression type is not match'); + }); + + it('if start with =, and type is match', () => { + const expression = { value: '=13', required: false, path: 'test', types: ['integer'] }; + const result = validate(expression, []); + expect(result).toBeNull(); + expression.value = '=true'; + expression.types[0] = 'boolean'; + const result1 = validate(expression, []); + expect(result1).toBeNull(); + }); + + it('use custom functions, but lg file does not export', () => { + const expression = { value: '=foo.bar()', required: false, path: 'test', types: ['boolean'] }; + const result = validate(expression, []); + expect(result).not.toBeNull(); + }); + + it('use custom functions, and lg file does export', () => { + const expression = { value: '=foo.bar()', required: false, path: 'test', types: ['boolean'] }; + const result = validate(expression, ['foo.bar']); + expect(result).toBeNull(); + }); +}); diff --git a/Composer/packages/lib/indexers/src/dialogIndexer.ts b/Composer/packages/lib/indexers/src/dialogIndexer.ts index cedb5f99c7..3301df81f3 100644 --- a/Composer/packages/lib/indexers/src/dialogIndexer.ts +++ b/Composer/packages/lib/indexers/src/dialogIndexer.ts @@ -10,15 +10,14 @@ import { DialogInfo, FileInfo, LgTemplateJsonPath, - Diagnostic, ReferredLuIntents, + Diagnostic, } from '@bfc/shared'; -import { createPath } from './dialogUtils/dialogChecker'; -import { checkerFuncs } from './dialogUtils/dialogChecker'; import { JsonWalk, VisitorFunc } from './utils/jsonWalk'; import { getBaseName } from './utils/help'; import ExtractIntentTriggers from './dialogUtils/extractIntentTriggers'; +import { createPath } from './validations/expressionValidation/utils'; // find out all lg templates given dialog function ExtractLgTemplates(id, dialog): LgTemplateJsonPath[] { const templates: LgTemplateJsonPath[] = []; @@ -155,55 +154,14 @@ function ExtractReferredDialogs(dialog): string[] { return uniq(dialogs); } -// check all fields -function CheckFields(dialog, id: string, schema: any): Diagnostic[] { - const diagnostics: Diagnostic[] = []; - /** - * - * @param path , jsonPath string - * @param value , current node value * - * @return boolean, true to stop walk - * */ - const visitor: VisitorFunc = (path: string, value: any): boolean => { - if (has(value, '$kind')) { - const allChecks = [...checkerFuncs['.']]; - const checkerFunc = checkerFuncs[value.$kind]; - if (checkerFunc) { - allChecks.splice(0, 0, ...checkerFunc); - } - - allChecks.forEach((func) => { - const result = func(path, value, value.$kind, schema.definitions[value.$kind]); - if (result) { - diagnostics.splice(0, 0, ...result); - } - }); - } - return false; - }; - JsonWalk(id, dialog, visitor); - return diagnostics.map((e) => { - e.source = id; - return e; - }); -} - -function validate(id: string, content, schema: any): Diagnostic[] { - try { - return CheckFields(content, id, schema); - } catch (error) { - return [new Diagnostic(error.message, id)]; - } -} - -function parse(id: string, content: any, schema: any) { +function parse(id: string, content: any) { const luFile = typeof content.recognizer === 'string' ? content.recognizer : ''; const lgFile = typeof content.generator === 'string' ? content.generator : ''; - + const diagnostics: Diagnostic[] = []; return { id, content, - diagnostics: validate(id, content, schema), + diagnostics, referredDialogs: ExtractReferredDialogs(content), lgTemplates: ExtractLgTemplates(id, content), referredLuIntents: ExtractLuIntents(content, id), @@ -214,7 +172,7 @@ function parse(id: string, content: any, schema: any) { }; } -function index(files: FileInfo[], botName: string, schema: any): DialogInfo[] { +function index(files: FileInfo[], botName: string): DialogInfo[] { const dialogs: DialogInfo[] = []; if (files.length !== 0) { for (const file of files) { @@ -226,7 +184,7 @@ function index(files: FileInfo[], botName: string, schema: any): DialogInfo[] { const dialog = { isRoot, displayName: isRoot ? `${botName}` : id, - ...parse(id, dialogJson, schema), + ...parse(id, dialogJson), }; dialogs.push(dialog); } diff --git a/Composer/packages/lib/indexers/src/dialogUtils/dialogChecker.ts b/Composer/packages/lib/indexers/src/dialogUtils/dialogChecker.ts deleted file mode 100644 index d3295618fb..0000000000 --- a/Composer/packages/lib/indexers/src/dialogUtils/dialogChecker.ts +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import get from 'lodash/get'; -import { FieldNames, Diagnostic } from '@bfc/shared'; -import values from 'lodash/values'; - -import { ExpressionType } from './validation'; -import { validate } from './validation'; - -type CheckerFunc = (path: string, value: any, type: string, schema: any) => Diagnostic[] | null; // error msg - -export const createPath = (path: string, type: string): string => { - let list = path.split('.'); - const matches = list.filter((x) => { - if (/\[|\]/.test(x)) { - const reg = /\[.*\]/; - x = x.replace(reg, ''); - return values(FieldNames).includes(x); - } - }); - - const focused = matches.join('.'); - list = path.split(`${focused}.`); - if (list.length !== 2) return `${path}#${type}`; - - return `${list[0]}${focused}#${type}#${list[1]}`; -}; - -function findAllRequiredProperties(schema: any): { [key: string]: boolean } { - if (!schema) return {}; - const types = schema.anyOf?.filter((x) => x.title === 'Type'); - const required = {}; - if (types && types.length) { - types[0].required.forEach((element: string) => { - required[element] = true; - }); - } - - if (schema.required) { - schema.required.forEach((element: string) => { - required[element] = true; - }); - } - return required; -} - -function findAllTypes(schema: any): string[] { - if (!schema) return []; - let types: string[] = []; - if (schema.type) { - if (Array.isArray(schema.type)) { - types = [...types, ...schema.type]; - } else { - types.push(schema.type); - } - } else { - types = schema.oneOf?.filter((item) => !!ExpressionType[item.type]).map((item) => item.type); - } - - return Array.from(new Set(types)); -} - -export const IsExpression: CheckerFunc = (path, value, type, schema) => { - if (!schema) return []; - const diagnostics: Diagnostic[] = []; - const requiredProperties = findAllRequiredProperties(schema); - Object.keys(value).forEach((key) => { - const property = value[key]; - if (Array.isArray(property)) { - const itemsSchema = get(schema, ['properties', key, 'items'], null); - if (itemsSchema?.$role === 'expression') { - property.forEach((child, index) => { - const diagnostic = validate( - child, - !!requiredProperties[key], - createPath(`${path}.${key}[${index}]`, type), - findAllTypes(itemsSchema) - ); - if (diagnostic) diagnostics.push(diagnostic); - }); - } else if (itemsSchema?.type === 'object') { - property.forEach((child, index) => { - const result = IsExpression(`${path}.${key}[${index}]`, child, type, itemsSchema); - if (result) diagnostics.splice(0, 0, ...result); - }); - } - } else if (get(schema.properties[key], '$role') === 'expression') { - const diagnostic = validate( - property, - !!requiredProperties[key], - createPath(`${path}.${key}`, type), - findAllTypes(schema.properties[key]) - ); - if (diagnostic) diagnostics.push(diagnostic); - } - }); - return diagnostics; -}; - -export const checkerFuncs: { [type: string]: CheckerFunc[] } = { - '.': [IsExpression], //this will check all types -}; diff --git a/Composer/packages/lib/indexers/src/dialogUtils/index.ts b/Composer/packages/lib/indexers/src/dialogUtils/index.ts deleted file mode 100644 index a12588127e..0000000000 --- a/Composer/packages/lib/indexers/src/dialogUtils/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -export * from './dialogChecker'; diff --git a/Composer/packages/lib/indexers/src/index.ts b/Composer/packages/lib/indexers/src/index.ts index 3775ac8178..b272107bdc 100644 --- a/Composer/packages/lib/indexers/src/index.ts +++ b/Composer/packages/lib/indexers/src/index.ts @@ -34,10 +34,10 @@ class Indexer { return importResolverGenerator(lgFiles, '.lg', locale); }; - public index(files: FileInfo[], botName: string, schema: any, locale: string) { + public index(files: FileInfo[], botName: string, locale: string) { const result = this.classifyFile(files); return { - dialogs: dialogIndexer.index(result[FileExtensions.Dialog], botName, schema), + dialogs: dialogIndexer.index(result[FileExtensions.Dialog], botName), lgFiles: lgIndexer.index(result[FileExtensions.lg], this.getLgImportResolver(result[FileExtensions.lg], locale)), luFiles: luIndexer.index(result[FileExtensions.Lu]), skillManifestFiles: skillManifestIndexer.index(result[FileExtensions.Manifest]), @@ -50,5 +50,5 @@ export const indexer = new Indexer(); export * from './dialogIndexer'; export * from './lgIndexer'; export * from './luIndexer'; -export * from './dialogUtils'; export * from './utils'; +export * from './validations'; diff --git a/Composer/packages/lib/indexers/src/lgIndexer.ts b/Composer/packages/lib/indexers/src/lgIndexer.ts index 504de5e375..5b2776bf0d 100644 --- a/Composer/packages/lib/indexers/src/lgIndexer.ts +++ b/Composer/packages/lib/indexers/src/lgIndexer.ts @@ -3,7 +3,7 @@ import { Templates, TemplatesParser, Diagnostic as LGDiagnostic, ImportResolverDelegate } from 'botbuilder-lg'; import get from 'lodash/get'; -import { LgTemplate, LgFile, FileInfo, Diagnostic, Position, Range } from '@bfc/shared'; +import { LgFile, FileInfo, Diagnostic, Position, Range } from '@bfc/shared'; import { getBaseName } from './utils/help'; @@ -20,11 +20,7 @@ function convertLGDiagnostic(d: LGDiagnostic, source: string): Diagnostic { return result; } -function parse( - content: string, - id = '', - importResolver: ImportResolverDelegate = defaultFileResolver -): { templates: LgTemplate[]; diagnostics: Diagnostic[] } { +function parse(content: string, id = '', importResolver: ImportResolverDelegate = defaultFileResolver) { const lgFile = Templates.parseText(content, id, importResolver); const templates = lgFile.toArray().map((t) => { return { @@ -40,7 +36,7 @@ function parse( const diagnostics = lgFile.diagnostics.map((d: LGDiagnostic) => { return convertLGDiagnostic(d, id); }); - return { templates, diagnostics }; + return { templates, diagnostics, options: lgFile.options }; } function index(files: FileInfo[], importResolver?: ImportResolverDelegate): LgFile[] { @@ -50,8 +46,7 @@ function index(files: FileInfo[], importResolver?: ImportResolverDelegate): LgFi const { name, content } = file; if (name.endsWith('.lg')) { const id = getBaseName(name, '.lg'); - const { templates, diagnostics } = parse(content, id, importResolver); - lgFiles.push({ id, content, templates, diagnostics }); + lgFiles.push({ id, content, ...parse(content, id, importResolver) }); } } return lgFiles; diff --git a/Composer/packages/lib/indexers/src/utils/lgUtil.ts b/Composer/packages/lib/indexers/src/utils/lgUtil.ts index d983cbc92e..dcbb92d025 100644 --- a/Composer/packages/lib/indexers/src/utils/lgUtil.ts +++ b/Composer/packages/lib/indexers/src/utils/lgUtil.ts @@ -131,3 +131,18 @@ export function checkSingleLgTemplate(template: LgTemplate) { throw new Error('Not a single template'); } } + +export function extractOptionByKey(nameOfKey: string, options: string[]): string { + let result = ''; + for (const option of options) { + if (nameOfKey && option.includes('=')) { + const index = option.indexOf('='); + const key = option.substring(0, index).trim().toLowerCase(); + const value = option.substring(index + 1).trim(); + if (key === nameOfKey) { + result = value; + } + } + } + return result; +} diff --git a/Composer/packages/lib/indexers/src/validations/expressionValidation/index.ts b/Composer/packages/lib/indexers/src/validations/expressionValidation/index.ts new file mode 100644 index 0000000000..3ba6eeabda --- /dev/null +++ b/Composer/packages/lib/indexers/src/validations/expressionValidation/index.ts @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { MicrosoftIDialog, Diagnostic, LgFile, LuFile } from '@bfc/shared'; +import { SchemaDefinitions } from '@bfc/shared/lib/schemaUtils/types'; + +import { extractOptionByKey } from '../../utils/lgUtil'; + +import { searchExpressions } from './searchExpression'; +import { ValidateFunc } from './types'; +import { validate } from './validation'; + +const NamespaceKey = '@namespace'; +const ExportsKey = '@exports'; + +export const searchLgCustomFunction = (lgFiles: LgFile[]): string[] => { + const customFunctions = lgFiles.reduce((result: string[], lgFile) => { + const { options } = lgFile; + const exports = extractOptionByKey(ExportsKey, options); + let namespace = extractOptionByKey(NamespaceKey, options); + if (!namespace) namespace = lgFile.id; //if namespace doesn't exist, use file name + const funcList = exports.split(','); + funcList.forEach((func) => { + if (func) { + result.push(`${namespace}.${func.trim()}`); + } + }); + + return result; + }, []); + return customFunctions; +}; + +export const validateExpressions: ValidateFunc = ( + path: string, + value: MicrosoftIDialog, + type: string, + schema: SchemaDefinitions, + lgFiles: LgFile[], + luFiles: LuFile[] +) => { + const expressions = searchExpressions(path, value, type, schema); + const customFunctions = searchLgCustomFunction(lgFiles); + + const diagnostics = expressions.reduce((diagnostics: Diagnostic[], expression) => { + const diagnostic = validate(expression, customFunctions); + if (diagnostic) diagnostics.push(diagnostic); + return diagnostics; + }, []); + + return diagnostics; +}; diff --git a/Composer/packages/lib/indexers/src/validations/expressionValidation/searchExpression.ts b/Composer/packages/lib/indexers/src/validations/expressionValidation/searchExpression.ts new file mode 100644 index 0000000000..949e73b141 --- /dev/null +++ b/Composer/packages/lib/indexers/src/validations/expressionValidation/searchExpression.ts @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import get from 'lodash/get'; +import { MicrosoftIDialog } from '@bfc/shared'; +import { SchemaDefinitions } from '@bfc/shared/lib/schemaUtils/types'; + +import { ExpressionProperty } from './types'; +import { findRequiredProperties, findTypes, createPath } from './utils'; + +export const searchExpressions = (path: string, value: MicrosoftIDialog, type: string, schema: SchemaDefinitions) => { + if (!schema) return []; + const expressions: ExpressionProperty[] = []; + const requiredProperties = findRequiredProperties(schema); + Object.keys(value).forEach((key) => { + const property = value[key]; + if (Array.isArray(property)) { + const itemsSchema = get(schema, ['properties', key, 'items'], null); + if (itemsSchema?.$role === 'expression') { + property.forEach((child, index) => { + expressions.push({ + value: child, + required: !!requiredProperties[key], + path: createPath(`${path}.${key}[${index}]`, type), + types: findTypes(itemsSchema), + }); + }); + } else if (itemsSchema?.type === 'object') { + property.forEach((child, index) => { + const result = searchExpressions(`${path}.${key}[${index}]`, child, type, itemsSchema); + if (result) expressions.splice(0, 0, ...result); + }); + } + } else if (get(schema.properties[key], '$role') === 'expression') { + expressions.push({ + value: property, + required: !!requiredProperties[key], + path: createPath(`${path}.${key}`, type), + types: findTypes(schema.properties[key]), + }); + } + }); + return expressions; +}; diff --git a/Composer/packages/lib/indexers/src/validations/expressionValidation/types.ts b/Composer/packages/lib/indexers/src/validations/expressionValidation/types.ts new file mode 100644 index 0000000000..cce4ab1a35 --- /dev/null +++ b/Composer/packages/lib/indexers/src/validations/expressionValidation/types.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Diagnostic, LgFile, LuFile } from '@bfc/shared'; + +export enum ExpressionType { + number = 'number', + integer = 'integer', + boolean = 'boolean', + string = 'string', + array = 'array', +} + +export type ValidateFunc = ( + path: string, + value: any, + type: string, + schema: any, + lgFiles: LgFile[], + luFiles: LuFile[] +) => Diagnostic[] | null; // error msg + +export type ExpressionProperty = { + value: string | boolean | number; + required: boolean; //=true, the value is required in dialog + path: string; //the json path of the value + types: string[]; //supported expression type of the value +}; diff --git a/Composer/packages/lib/indexers/src/validations/expressionValidation/utils.ts b/Composer/packages/lib/indexers/src/validations/expressionValidation/utils.ts new file mode 100644 index 0000000000..caf2c0e303 --- /dev/null +++ b/Composer/packages/lib/indexers/src/validations/expressionValidation/utils.ts @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import values from 'lodash/values'; +import { FieldNames } from '@bfc/shared'; + +import { ExpressionType } from './types'; + +export const createPath = (path: string, type: string): string => { + let list = path.split('.'); + const matches = list.filter((x) => { + if (/\[|\]/.test(x)) { + const reg = /\[.*\]/; + x = x.replace(reg, ''); + return values(FieldNames).includes(x); + } + }); + + const focused = matches.join('.'); + list = path.split(`${focused}.`); + if (list.length !== 2) return `${path}#${type}`; + + return `${list[0]}${focused}#${type}#${list[1]}`; +}; + +export function findRequiredProperties(schema: any): { [key: string]: boolean } { + if (!schema) return {}; + const types = schema.anyOf?.filter((x) => x.title === 'Type'); + const required = {}; + if (types && types.length) { + types[0].required.forEach((element: string) => { + required[element] = true; + }); + } + + if (schema.required) { + schema.required.forEach((element: string) => { + required[element] = true; + }); + } + return required; +} + +export function findTypes(schema: any): string[] { + if (!schema) return []; + let types: string[] = []; + if (schema.type) { + if (Array.isArray(schema.type)) { + types = [...types, ...schema.type]; + } else { + types.push(schema.type); + } + } else { + types = schema.oneOf?.filter((item) => !!ExpressionType[item.type]).map((item) => item.type); + } + + return Array.from(new Set(types)); +} diff --git a/Composer/packages/lib/indexers/src/dialogUtils/validation.ts b/Composer/packages/lib/indexers/src/validations/expressionValidation/validation.ts similarity index 70% rename from Composer/packages/lib/indexers/src/dialogUtils/validation.ts rename to Composer/packages/lib/indexers/src/validations/expressionValidation/validation.ts index be66900cae..5b95faa59b 100644 --- a/Composer/packages/lib/indexers/src/dialogUtils/validation.ts +++ b/Composer/packages/lib/indexers/src/validations/expressionValidation/validation.ts @@ -3,14 +3,12 @@ import { Expression, ReturnType } from 'adaptive-expressions'; import formatMessage from 'format-message'; import { Diagnostic } from '@bfc/shared'; +import startsWith from 'lodash/startsWith'; -export const ExpressionType = { - number: 'number', - integer: 'integer', - boolean: 'boolean', - string: 'string', - array: 'array', -}; +import { ExpressionType, ExpressionProperty } from './types'; + +const customFunctionErrorMessage = (func: string) => + `Error: ${func} does not have an evaluator, it's not a built-in function or a custom function`; const ExpressionTypeMapString = { [ReturnType.Number]: 'number', @@ -35,7 +33,12 @@ const checkReturnType = (returnType: ReturnType, types: string[]): string => { : formatMessage('the return type does not match'); }; -export const checkExpression = (exp: string | boolean | number, required: boolean, types: string[]): string => { +export const checkExpression = ( + exp: string | boolean | number, + required: boolean, + types: string[], + customFunctions: string[] +): string => { let message = ''; let returnType: ReturnType = ReturnType.Object; switch (typeof exp) { @@ -52,7 +55,14 @@ export const checkExpression = (exp: string | boolean | number, required: boolea try { returnType = Expression.parse(exp).returnType; } catch (error) { - message = `${formatMessage('must be an expression:')} ${error})`; + if ( + customFunctions.length && + customFunctions.some((item) => startsWith(error, customFunctionErrorMessage(item))) + ) { + message = ''; + } else { + message = `${formatMessage('must be an expression:')} ${error})`; + } } } } @@ -60,12 +70,9 @@ export const checkExpression = (exp: string | boolean | number, required: boolea return message; }; -export const validate = ( - value: string | boolean | number, - required: boolean, - path: string, - types: string[] -): Diagnostic | null => { +export const validate = (expression: ExpressionProperty, customFunctions: string[]): Diagnostic | null => { + const { required, path, types } = expression; + let value = expression.value; //if there is no type do nothing //if the json type length more than 2, the type assumes string interpolation if (!types.length || types.length > 2 || !isExpression(value, types)) { @@ -77,7 +84,7 @@ export const validate = ( value = value.substring(1); } - const message = checkExpression(value, required, types); + const message = checkExpression(value, required, types, customFunctions); if (!message) return null; const diagnostic = new Diagnostic(message, ''); diff --git a/Composer/packages/lib/indexers/src/validations/index.ts b/Composer/packages/lib/indexers/src/validations/index.ts new file mode 100644 index 0000000000..e2adcf1c05 --- /dev/null +++ b/Composer/packages/lib/indexers/src/validations/index.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { Diagnostic, MicrosoftIDialog, DialogInfo, LgFile, LuFile } from '@bfc/shared'; +import { SchemaDefinitions } from '@bfc/shared/lib/schemaUtils/types'; +import has from 'lodash/has'; + +import { JsonWalk, VisitorFunc } from '..'; + +import { validateExpressions } from './expressionValidation/index'; +import { ValidateFunc } from './expressionValidation/types'; + +export const validateFuncs: { [type: string]: ValidateFunc[] } = { + '.': [validateExpressions], //this will check all types +}; + +// check all fields +function validateFields( + dialog: MicrosoftIDialog, + id: string, + schema: any, + lgFiles: LgFile[], + luFiles: LuFile[] +): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + /** + * + * @param path , jsonPath string + * @param value , current node value * + * @return boolean, true to stop walk + * */ + const visitor: VisitorFunc = (path: string, value: any): boolean => { + if (has(value, '$kind')) { + const allChecks = [...validateFuncs['.']]; + const checkerFunc = validateFuncs[value.$kind]; + if (checkerFunc) { + allChecks.splice(0, 0, ...checkerFunc); + } + + allChecks.forEach((func) => { + const result = func(path, value, value.$kind, schema.definitions[value.$kind], lgFiles, luFiles); + if (result) { + diagnostics.splice(0, 0, ...result); + } + }); + } + return false; + }; + JsonWalk(id, dialog, visitor); + return diagnostics.map((e) => { + e.source = id; + return e; + }); +} + +export function validateDialog( + dialog: DialogInfo, + schema: SchemaDefinitions, + lgFiles: LgFile[], + luFiles: LuFile[] +): Diagnostic[] { + const { id, content } = dialog; + try { + return validateFields(content, id, schema, lgFiles, luFiles); + } catch (error) { + return [new Diagnostic(error.message, id)]; + } +} + +export function validateDialogs( + dialogs: DialogInfo[], + lgFiles: LgFile[], + luFiles: LuFile[], + schema: SchemaDefinitions +): { [id: string]: Diagnostic[] } { + const diagnosticsMap = {}; + dialogs.forEach((dialog) => { + diagnosticsMap[dialog.id] = validateDialog(dialog, schema, lgFiles, luFiles); + }); + + return diagnosticsMap; +} diff --git a/Composer/packages/lib/shared/src/types/indexers.ts b/Composer/packages/lib/shared/src/types/indexers.ts index 130b3f0f18..be850e6843 100644 --- a/Composer/packages/lib/shared/src/types/indexers.ts +++ b/Composer/packages/lib/shared/src/types/indexers.ts @@ -107,6 +107,7 @@ export interface LgFile { content: string; diagnostics: Diagnostic[]; templates: LgTemplate[]; + options: string[]; } export interface Skill {