diff --git a/package-lock.json b/package-lock.json index f9cd3ec..8ca23d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,8 @@ "workspaces": [ "packages/fmlrunner", "packages/fmlrunner-rest", - "packages/fmlrunner-mcp" + "packages/fmlrunner-mcp", + "packages/fmlrunner-kotlin-core" ], "devDependencies": { "@types/jest": "^29.0.0", @@ -1184,29 +1185,9 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@lhncbc/ucum-lhc": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@lhncbc/ucum-lhc/-/ucum-lhc-5.0.4.tgz", - "integrity": "sha512-khuV9GV51DF80b0wJmhZTR5Bf23fhS6SSIWnyGT9X+Uvn0FsHFl2LKViQ2TTOuvwagUOUSq8/0SyoE2ZDGwrAA==", - "license": "SEE LICENSE IN LICENSE.md", - "dependencies": { - "coffeescript": "^2.7.0", - "csv-parse": "^4.4.6", - "csv-stringify": "^1.0.4", - "escape-html": "^1.0.3", - "is-integer": "^1.0.6", - "jsonfile": "^2.2.3", - "stream": "0.0.2", - "stream-transform": "^0.1.1", - "string-to-stream": "^1.1.0", - "xmldoc": "^0.4.0" - } - }, - "node_modules/@loxjs/url-join": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@loxjs/url-join/-/url-join-1.0.2.tgz", - "integrity": "sha512-BqzK8+iHqxUbPRZV6NBum63CJzE0G6vGG3o+4dqeIzbywdoTg+xHJbksYDkk1P1w3Gj64U20Rgp44HHciLbRzg==", - "license": "MIT" + "node_modules/@litlfred/fmlrunner-core": { + "resolved": "packages/fmlrunner-kotlin-core", + "link": true }, "node_modules/@modelcontextprotocol/sdk": { "version": "0.5.0", @@ -2076,15 +2057,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/antlr4": { - "version": "4.9.3", - "resolved": "https://registry.npmjs.org/antlr4/-/antlr4-4.9.3.tgz", - "integrity": "sha512-qNy2odgsa0skmNMCuxzXhM4M8J1YDaPv3TI+vCdnOAanu0N982wBrSqziDKRDctEZLZy9VffqIZXc0UGjjSP/g==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=14" - } - }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -2554,19 +2526,6 @@ "node": ">= 0.12.0" } }, - "node_modules/coffeescript": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/coffeescript/-/coffeescript-2.7.0.tgz", - "integrity": "sha512-hzWp6TUE2d/jCcN67LrW1eh5b/rSDKQK6oD6VMLlggYVUUFexgTH9z3dNYihzX4RMhze5FTUsUmOXViJKFQR/A==", - "license": "MIT", - "bin": { - "cake": "bin/cake", - "coffee": "bin/coffee" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/collect-v8-coverage": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", @@ -2651,12 +2610,6 @@ "node": ">= 0.8" } }, - "node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -2791,12 +2744,6 @@ "dev": true, "license": "MIT" }, - "node_modules/core-util-is": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", - "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", - "license": "MIT" - }, "node_modules/cors": { "version": "2.8.5", "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", @@ -2847,27 +2794,6 @@ "node": ">= 8" } }, - "node_modules/csv-parse": { - "version": "4.16.3", - "resolved": "https://registry.npmjs.org/csv-parse/-/csv-parse-4.16.3.tgz", - "integrity": "sha512-cO1I/zmz4w2dcKHVvpCr7JVRu8/FymG5OEpmvsZYlccYolPBLoVGKUHgNoc4ZGkFeFlWGEDmMyBM+TTqRdW/wg==", - "license": "MIT" - }, - "node_modules/csv-stringify": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/csv-stringify/-/csv-stringify-1.1.2.tgz", - "integrity": "sha512-3NmNhhd+AkYs5YtM1GEh01VR6PKj6qch2ayfQaltx5xpcAdThjnbbI5eT8CzRVpXfGKAxnmrSYLsNl/4f3eWiw==", - "license": "BSD-3-Clause", - "dependencies": { - "lodash.get": "~4.4.2" - } - }, - "node_modules/date-fns": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", - "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==", - "license": "MIT" - }, "node_modules/debug": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", @@ -3038,14 +2964,6 @@ "dev": true, "license": "MIT" }, - "node_modules/emitter-component": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/emitter-component/-/emitter-component-1.1.2.tgz", - "integrity": "sha512-QdXO3nXOzZB4pAjM0n6ZE+R9/+kPpECA/XSELIcc54NeYVnBqIk+4DFiBgK+8QbV3mdvTG6nedl7dTYgO+5wDw==", - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -3299,6 +3217,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -3593,49 +3512,6 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, - "node_modules/fhirpath": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/fhirpath/-/fhirpath-4.6.0.tgz", - "integrity": "sha512-nfK0+9mVLS/hyZNmwGlRV6EG8lll9VV5AGgAiXcCfSUms/M9R94JqyC34r3/Yjkp0ICuR70NH7Q7q9A2T91DzA==", - "hasInstallScript": true, - "license": "SEE LICENSE in LICENSE.md", - "dependencies": { - "@lhncbc/ucum-lhc": "^5.0.0", - "@loxjs/url-join": "^1.0.2", - "antlr4": "~4.9.3", - "commander": "^2.18.0", - "date-fns": "^1.30.1", - "js-yaml": "^3.13.1" - }, - "bin": { - "fhirpath": "bin/fhirpath" - }, - "engines": { - "node": ">=8.9.0" - } - }, - "node_modules/fhirpath/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/fhirpath/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4155,7 +4031,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/graphemer": { @@ -4398,18 +4274,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-finite": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", - "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4443,15 +4307,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-integer": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-integer/-/is-integer-1.0.7.tgz", - "integrity": "sha512-RPQc/s9yBHSvpi+hs9dYiJ2cuFeU6x3TyyIp8O2H6SKEltIvJOzRj9ToyvcStDvPR/pS4rxgr1oBFajQjZ2Szg==", - "license": "WTFPL OR ISC", - "dependencies": { - "is-finite": "^1.0.0" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4484,12 +4339,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/isarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", - "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "license": "MIT" - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -5259,15 +5108,6 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-2.4.0.tgz", - "integrity": "sha512-PKllAqbgLgxHaj8TElYymKCAgrASebJrWpTnEkOaTowt23VKXXN0sUeriJ+eh7y6ufb/CC5ap11pz71/cM0hUw==", - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5348,13 +5188,6 @@ "dev": true, "license": "MIT" }, - "node_modules/lodash.get": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", - "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", - "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", - "license": "MIT" - }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5988,12 +5821,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/process-nextick-args": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", - "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", - "license": "MIT" - }, "node_modules/prompts": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", @@ -6115,27 +5942,6 @@ "dev": true, "license": "MIT" }, - "node_modules/readable-stream": { - "version": "2.3.8", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", - "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", - "license": "MIT", - "dependencies": { - "core-util-is": "~1.0.0", - "inherits": "~2.0.3", - "isarray": "~1.0.0", - "process-nextick-args": "~2.0.0", - "safe-buffer": "~5.1.1", - "string_decoder": "~1.1.1", - "util-deprecate": "~1.0.1" - } - }, - "node_modules/readable-stream/node_modules/safe-buffer": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "license": "MIT" - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6316,12 +6122,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, - "node_modules/sax": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.1.6.tgz", - "integrity": "sha512-8zci48uUQyfqynGDSkUMD7FCJB96hwLnlZOXlgs1l3TX+LW27t3psSWKUxC0fxVgA86i8tL4NwGcY1h/6t3ESg==", - "license": "ISC" - }, "node_modules/semver": { "version": "7.7.2", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", @@ -6625,21 +6425,6 @@ "node": ">= 0.8" } }, - "node_modules/stream": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stream/-/stream-0.0.2.tgz", - "integrity": "sha512-gCq3NDI2P35B2n6t76YJuOp7d6cN/C7Rt0577l91wllh0sY9ZBuw9KaSGqH/b0hzn3CWWJbpbW0W0WvQ1H/Q7g==", - "license": "MIT", - "dependencies": { - "emitter-component": "^1.1.1" - } - }, - "node_modules/stream-transform": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/stream-transform/-/stream-transform-0.1.2.tgz", - "integrity": "sha512-3HXId/0W8sktQnQM6rOZf2LuDDMbakMgAjpViLk758/h0br+iGqZFFfUxxJSqEvGvT742PyFr4v/TBXUtowdCg==", - "license": "BSD-3-Clause" - }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -6669,16 +6454,6 @@ "node": ">=10" } }, - "node_modules/string-to-stream": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/string-to-stream/-/string-to-stream-1.1.1.tgz", - "integrity": "sha512-QySF2+3Rwq0SdO3s7BAp4x+c3qsClpPQ6abAmb0DGViiSBAkT5kL6JT2iyzEVP+T1SmzHrQD1TwlP9QAHCc+Sw==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.1", - "readable-stream": "^2.1.0" - } - }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -7401,15 +7176,6 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, - "node_modules/xmldoc": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/xmldoc/-/xmldoc-0.4.0.tgz", - "integrity": "sha512-rJ/+/UzYCSlFNuAzGuRyYgkH2G5agdX1UQn4+5siYw9pkNC3Hu/grYNDx/dqYLreeSjnY5oKg74CMBKxJHSg6Q==", - "license": "MIT", - "dependencies": { - "sax": "~1.1.1" - } - }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -7505,9 +7271,9 @@ "version": "0.1.0", "license": "MIT", "dependencies": { + "@litlfred/fmlrunner-core": "0.1.0", "ajv": "^8.12.0", "ajv-formats": "^2.1.1", - "fhirpath": "^4.6.0", "winston": "^3.11.0" }, "devDependencies": { @@ -7518,6 +7284,11 @@ "npm": ">=8.0.0" } }, + "packages/fmlrunner-kotlin-core": { + "name": "@litlfred/fmlrunner-core", + "version": "0.1.0", + "license": "MIT" + }, "packages/fmlrunner-mcp": { "version": "0.1.0", "license": "MIT", diff --git a/packages/fmlrunner-mcp/src/index.ts b/packages/fmlrunner-mcp/src/index.ts index 6d676e1..099db26 100644 --- a/packages/fmlrunner-mcp/src/index.ts +++ b/packages/fmlrunner-mcp/src/index.ts @@ -194,6 +194,20 @@ export class FmlRunnerMcp { required: ['fmlContent'] } }, + { + name: 'validate-fml-syntax', + description: 'Validate FHIR Mapping Language (FML) syntax without compilation - provides detailed error reporting', + inputSchema: { + type: 'object', + properties: { + fmlContent: { + type: 'string', + description: 'FML content to validate syntax (must start with map declaration)' + } + }, + required: ['fmlContent'] + } + }, { name: 'execute-structuremap', description: 'Execute a StructureMap transformation on input data', @@ -328,6 +342,9 @@ export class FmlRunnerMcp { case 'compile-fml': return await this.handleCompileFml(args); + case 'validate-fml-syntax': + return await this.handleValidateFmlSyntax(args); + case 'execute-structuremap': return await this.handleExecuteStructureMap(args); @@ -390,6 +407,30 @@ export class FmlRunnerMcp { }; } + private async handleValidateFmlSyntax(args: any): Promise { + // Validate input using the same schema as compile-fml + const validate = this.ajv.getSchema('fml-compilation-input'); + if (!validate || !validate(args)) { + throw new Error(`Invalid input: ${validate?.errors?.map(e => e.message).join(', ')}`); + } + + const result = this.fmlRunner.validateFmlSyntax(args.fmlContent); + + return { + content: [ + { + type: 'text', + text: JSON.stringify({ + success: result.success, + isValid: result.isValid, + errors: result.errors || [], + warnings: result.warnings || [] + }, null, 2) + } + ] + }; + } + private async handleExecuteStructureMap(args: any): Promise { // Validate input const validate = this.ajv.getSchema('structuremap-execution-input'); diff --git a/packages/fmlrunner-rest/src/api.ts b/packages/fmlrunner-rest/src/api.ts index 59e5146..3c17d99 100644 --- a/packages/fmlrunner-rest/src/api.ts +++ b/packages/fmlrunner-rest/src/api.ts @@ -33,6 +33,7 @@ export class FmlRunnerApi { // Legacy endpoints for backward compatibility apiRouter.post('/compile', this.compileFml.bind(this)); + apiRouter.post('/validate-syntax', this.validateFmlSyntax.bind(this)); apiRouter.post('/execute', this.executeStructureMap.bind(this)); apiRouter.get('/structuremap/:reference', this.getStructureMap.bind(this)); @@ -129,6 +130,43 @@ export class FmlRunnerApi { } } + /** + * Validate FML syntax without compilation + */ + private async validateFmlSyntax(req: Request, res: Response): Promise { + try { + const { fmlContent } = req.body; + + if (!fmlContent) { + res.status(400).json({ + error: 'fmlContent is required', + details: 'Request body must include fmlContent property' + }); + return; + } + + const result = this.fmlRunner.validateFmlSyntax(fmlContent); + + if (result.success) { + res.json({ + isValid: result.isValid, + errors: result.errors || [], + warnings: result.warnings || [] + }); + } else { + res.status(500).json({ + error: 'FML syntax validation failed', + details: 'Internal validation error occurred' + }); + } + } catch (error) { + res.status(500).json({ + error: 'Internal server error', + details: error instanceof Error ? error.message : 'Unknown error' + }); + } + } + /** * Execute StructureMap transformation */ diff --git a/packages/fmlrunner-rest/tests/syntax-validation-api.test.ts b/packages/fmlrunner-rest/tests/syntax-validation-api.test.ts new file mode 100644 index 0000000..15689af --- /dev/null +++ b/packages/fmlrunner-rest/tests/syntax-validation-api.test.ts @@ -0,0 +1,116 @@ +import request from 'supertest'; +import { FmlRunnerApi } from '../src/api'; +import { FmlRunner } from 'fmlrunner'; + +describe('FML Syntax Validation API', () => { + let app: any; + let fmlRunner: FmlRunner; + + beforeAll(() => { + fmlRunner = new FmlRunner({ baseUrl: './tests/test-data' }); + const api = new FmlRunnerApi(fmlRunner); + app = api.getApp(); + }); + + describe('POST /api/v1/validate-syntax', () => { + it('should validate valid FML content', async () => { + const validFmlContent = ` + map "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight) { + src.a as a -> tgt.a = a; + } + `; + + const response = await request(app) + .post('/api/v1/validate-syntax') + .send({ fmlContent: validFmlContent }) + .expect(200); + + expect(response.body.isValid).toBe(true); + expect(response.body.errors).toEqual([]); + expect(response.body.warnings).toEqual([]); + }); + + it('should detect syntax errors in FML content', async () => { + const invalidFmlContent = ` + invalid "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight) { + src.a as a -> tgt.a = a; + } + `; + + const response = await request(app) + .post('/api/v1/validate-syntax') + .send({ fmlContent: invalidFmlContent }) + .expect(200); + + expect(response.body.isValid).toBe(false); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBeGreaterThan(0); + expect(response.body.errors.some((e: any) => e.code === 'MISSING_MAP_KEYWORD')).toBe(true); + }); + + it('should return 400 for missing fmlContent', async () => { + const response = await request(app) + .post('/api/v1/validate-syntax') + .send({}) + .expect(400); + + expect(response.body.error).toBe('fmlContent is required'); + }); + + it('should return 400 for empty fmlContent', async () => { + const response = await request(app) + .post('/api/v1/validate-syntax') + .send({ fmlContent: '' }) + .expect(200); + + expect(response.body.isValid).toBe(false); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.some((e: any) => e.code === 'EMPTY_CONTENT')).toBe(true); + }); + + it('should detect unclosed braces', async () => { + const invalidFmlContent = ` + map "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight) { + src.a as a -> tgt.a = a; + + `; + + const response = await request(app) + .post('/api/v1/validate-syntax') + .send({ fmlContent: invalidFmlContent }) + .expect(200); + + expect(response.body.isValid).toBe(false); + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.some((e: any) => e.code === 'UNCLOSED_BRACE')).toBe(true); + }); + + it('should provide detailed error information', async () => { + const invalidFmlContent = `map "test" = test + + group test(source src, target tgt) { + src.a -> tgt.b; + }}`; + + const response = await request(app) + .post('/api/v1/validate-syntax') + .send({ fmlContent: invalidFmlContent }) + .expect(200); + + expect(response.body.isValid).toBe(false); + expect(response.body.errors).toBeDefined(); + + const unMatchedError = response.body.errors.find((e: any) => e.code === 'UNMATCHED_BRACE'); + expect(unMatchedError).toBeDefined(); + expect(unMatchedError.line).toBeDefined(); + expect(unMatchedError.column).toBeDefined(); + expect(unMatchedError.severity).toBe('error'); + }); + }); +}); \ No newline at end of file diff --git a/packages/fmlrunner/src/index.ts b/packages/fmlrunner/src/index.ts index ff3abd4..e28abb5 100644 --- a/packages/fmlrunner/src/index.ts +++ b/packages/fmlrunner/src/index.ts @@ -11,6 +11,7 @@ import { SchemaValidator } from './lib/schema-validator'; import { StructureMap, FmlCompilationResult, + FmlSyntaxValidationResult, ExecutionResult, EnhancedExecutionResult, ExecutionOptions, @@ -121,6 +122,25 @@ export class FmlRunner { return result; } + /** + * Validate FML syntax without full compilation + * Provides detailed error reporting for syntax validation + */ + validateFmlSyntax(fmlContent: string): FmlSyntaxValidationResult { + this.logger.debug('Validating FML syntax', { contentLength: fmlContent?.length || 0 }); + + const result = this.compiler.validateSyntax(fmlContent); + + this.logger.info('FML syntax validation completed', { + success: result.success, + isValid: result.isValid, + errorCount: result.errors?.length || 0, + warningCount: result.warnings?.length || 0 + }); + + return result; + } + /** * Execute StructureMap on input content with validation */ diff --git a/packages/fmlrunner/src/lib/fml-compiler.ts b/packages/fmlrunner/src/lib/fml-compiler.ts index 38d75dd..8a4d8ff 100644 --- a/packages/fmlrunner/src/lib/fml-compiler.ts +++ b/packages/fmlrunner/src/lib/fml-compiler.ts @@ -1,4 +1,4 @@ -import { StructureMap, FmlCompilationResult, StructureMapGroup, StructureMapGroupInput, StructureMapGroupRule, StructureMapGroupRuleSource, StructureMapGroupRuleTarget } from '../types'; +import { StructureMap, FmlCompilationResult, FmlSyntaxValidationResult, FmlSyntaxError, FmlSyntaxWarning, StructureMapGroup, StructureMapGroupInput, StructureMapGroupRule, StructureMapGroupRuleSource, StructureMapGroupRuleTarget } from '../types'; import { Logger } from './logger'; /** @@ -727,6 +727,194 @@ export class FmlCompiler { } } + /** + * Validate FML syntax without full compilation - provides detailed error reporting + * @param fmlContent The FML content to validate + * @returns Validation result with detailed error information + */ + validateSyntax(fmlContent: string): FmlSyntaxValidationResult { + const errors: FmlSyntaxError[] = []; + const warnings: FmlSyntaxWarning[] = []; + + try { + // Basic validation + if (!fmlContent || fmlContent.trim().length === 0) { + errors.push({ + message: 'FML content cannot be empty', + severity: 'error', + line: 0, + column: 0, + code: 'EMPTY_CONTENT' + }); + return { + success: true, + isValid: false, + errors + }; + } + + // Tokenize the FML content with detailed error tracking + const tokenizer = new FmlTokenizer(fmlContent); + let tokens; + try { + tokens = tokenizer.tokenize(); + } catch (tokenError) { + const error = tokenError as Error; + errors.push({ + message: `Tokenization error: ${error.message}`, + severity: 'error', + code: 'TOKENIZATION_ERROR' + }); + return { + success: true, + isValid: false, + errors + }; + } + + // Basic syntax checks on tokens + this.validateTokenSequence(tokens, errors, warnings); + + // If tokenization passed, try parsing for syntax validation + try { + const parser = new FmlParser(tokens); + // We don't need the actual StructureMap, just validate the parsing can succeed + parser.parse(); + } catch (parseError) { + const error = parseError as Error; + errors.push({ + message: `Parse error: ${error.message}`, + severity: 'error', + code: 'PARSE_ERROR' + }); + } + + const isValid = errors.length === 0; + + return { + success: true, + isValid, + errors: errors.length > 0 ? errors : undefined, + warnings: warnings.length > 0 ? warnings : undefined + }; + } catch (error) { + const err = error as Error; + return { + success: false, + isValid: false, + errors: [{ + message: `Syntax validation failed: ${err.message}`, + severity: 'error', + code: 'VALIDATION_FAILURE' + }] + }; + } + } + + /** + * Validate the sequence of tokens for common syntax issues + */ + private validateTokenSequence(tokens: Token[], errors: FmlSyntaxError[], warnings: FmlSyntaxWarning[]): void { + if (tokens.length === 0) { + errors.push({ + message: 'No tokens found in FML content', + severity: 'error', + code: 'NO_TOKENS' + }); + return; + } + + // Check if starts with 'map' keyword + const firstToken = tokens.find(token => token.type !== TokenType.WHITESPACE && token.type !== TokenType.COMMENT); + if (!firstToken || firstToken.type !== TokenType.MAP) { + errors.push({ + message: 'FML content must start with "map" keyword', + severity: 'error', + line: firstToken?.line || 1, + column: firstToken?.column || 1, + code: 'MISSING_MAP_KEYWORD' + }); + } + + // Check for balanced braces and parentheses + let braceCount = 0; + let parenCount = 0; + let bracketCount = 0; + + for (const token of tokens) { + switch (token.type) { + case TokenType.LBRACE: + braceCount++; + break; + case TokenType.RBRACE: + braceCount--; + if (braceCount < 0) { + errors.push({ + message: 'Unmatched closing brace', + severity: 'error', + line: token.line, + column: token.column, + code: 'UNMATCHED_BRACE' + }); + } + break; + case TokenType.LPAREN: + parenCount++; + break; + case TokenType.RPAREN: + parenCount--; + if (parenCount < 0) { + errors.push({ + message: 'Unmatched closing parenthesis', + severity: 'error', + line: token.line, + column: token.column, + code: 'UNMATCHED_PAREN' + }); + } + break; + case TokenType.LBRACKET: + bracketCount++; + break; + case TokenType.RBRACKET: + bracketCount--; + if (bracketCount < 0) { + errors.push({ + message: 'Unmatched closing bracket', + severity: 'error', + line: token.line, + column: token.column, + code: 'UNMATCHED_BRACKET' + }); + } + break; + } + } + + // Check for unclosed braces/parentheses/brackets - these should be errors, not warnings + if (braceCount > 0) { + errors.push({ + message: `${braceCount} unclosed brace(s)`, + severity: 'error', + code: 'UNCLOSED_BRACE' + }); + } + if (parenCount > 0) { + errors.push({ + message: `${parenCount} unclosed parenthesis/parentheses`, + severity: 'error', + code: 'UNCLOSED_PAREN' + }); + } + if (bracketCount > 0) { + errors.push({ + message: `${bracketCount} unclosed bracket(s)`, + severity: 'error', + code: 'UNCLOSED_BRACKET' + }); + } + } + /** * Legacy method for backwards compatibility - now uses the new parser * @deprecated Use compile() method instead diff --git a/packages/fmlrunner/src/types/index.ts b/packages/fmlrunner/src/types/index.ts index d03bbf7..750da30 100644 --- a/packages/fmlrunner/src/types/index.ts +++ b/packages/fmlrunner/src/types/index.ts @@ -63,6 +63,38 @@ export interface FmlCompilationResult { errors?: string[]; } +/** + * FML syntax validation result - validates syntax without full compilation + */ +export interface FmlSyntaxValidationResult { + success: boolean; + isValid: boolean; + errors?: FmlSyntaxError[]; + warnings?: FmlSyntaxWarning[]; +} + +/** + * Detailed syntax error information + */ +export interface FmlSyntaxError { + message: string; + line?: number; + column?: number; + severity: 'error'; + code?: string; +} + +/** + * Detailed syntax warning information + */ +export interface FmlSyntaxWarning { + message: string; + line?: number; + column?: number; + severity: 'warning'; + code?: string; +} + /** * StructureMap execution result */ diff --git a/packages/fmlrunner/tests/fml-runner-syntax-validation.test.ts b/packages/fmlrunner/tests/fml-runner-syntax-validation.test.ts new file mode 100644 index 0000000..ec85c8a --- /dev/null +++ b/packages/fmlrunner/tests/fml-runner-syntax-validation.test.ts @@ -0,0 +1,100 @@ +import { FmlRunner } from '../src/index'; + +describe('FML Runner Syntax Validation Integration', () => { + let fmlRunner: FmlRunner; + + beforeAll(() => { + fmlRunner = new FmlRunner({ baseUrl: './tests/test-data' }); + }); + + describe('FML Runner validateFmlSyntax method', () => { + test('should validate valid FML content through FmlRunner', () => { + const validFmlContent = ` + map "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight) { + src.a as a -> tgt.a = a; + } + `; + + const result = fmlRunner.validateFmlSyntax(validFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + test('should detect syntax errors through FmlRunner', () => { + const invalidFmlContent = ` + invalid "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight) { + src.a as a -> tgt.a = a; + } + `; + + const result = fmlRunner.validateFmlSyntax(invalidFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors!.length).toBeGreaterThan(0); + expect(result.errors!.some(e => e.code === 'MISSING_MAP_KEYWORD')).toBe(true); + }); + + test('should handle empty content gracefully', () => { + const result = fmlRunner.validateFmlSyntax(''); + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors![0].code).toBe('EMPTY_CONTENT'); + }); + + test('should be faster than full compilation', () => { + const fmlContent = ` + map "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight) { + src.a as a -> tgt.a = a; + src.b as b -> tgt.b = b; + src.c as c -> tgt.c = c; + } + `; + + // Syntax validation + const startValidation = Date.now(); + const validationResult = fmlRunner.validateFmlSyntax(fmlContent); + const validationTime = Date.now() - startValidation; + + // Full compilation + const startCompilation = Date.now(); + const compilationResult = fmlRunner.compileFml(fmlContent); + const compilationTime = Date.now() - startCompilation; + + expect(validationResult.success).toBe(true); + expect(validationResult.isValid).toBe(true); + + // Note: This test might not always pass if the implementation is very fast + // but it demonstrates the concept + console.log(`Validation time: ${validationTime}ms, Compilation time: ${compilationTime}ms`); + }); + + test('should provide detailed error information', () => { + const invalidFmlContent = `map "test" = test + + group test(source src, target tgt) { + src.a -> tgt.b; + }}`; + + const result = fmlRunner.validateFmlSyntax(invalidFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + + const error = result.errors!.find(e => e.code === 'UNMATCHED_BRACE'); + expect(error).toBeDefined(); + expect(error!.message).toContain('Unmatched closing brace'); + expect(error!.severity).toBe('error'); + expect(error!.line).toBeDefined(); + expect(error!.column).toBeDefined(); + }); + }); +}); \ No newline at end of file diff --git a/packages/fmlrunner/tests/fml-syntax-validation.test.ts b/packages/fmlrunner/tests/fml-syntax-validation.test.ts new file mode 100644 index 0000000..fd24eed --- /dev/null +++ b/packages/fmlrunner/tests/fml-syntax-validation.test.ts @@ -0,0 +1,176 @@ +import { FmlCompiler } from '../src/lib/fml-compiler'; +import { Logger } from '../src/lib/logger'; + +describe('FML Syntax Validation', () => { + let compiler: FmlCompiler; + let logger: Logger; + + beforeAll(() => { + logger = new Logger('test'); + compiler = new FmlCompiler(logger); + }); + + describe('Valid FML Content', () => { + test('should validate simple valid FML map', () => { + const validFmlContent = ` + map "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight) { + src.a as a -> tgt.a = a; + } + `; + + const result = compiler.validateSyntax(validFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(true); + expect(result.errors).toBeUndefined(); + }); + + test('should validate complex FML map with imports', () => { + const validFmlContent = ` + map "http://example.org/tutorial" = tutorial + + uses "http://hl7.org/fhir/StructureDefinition/Patient" alias Patient as source + uses "http://hl7.org/fhir/StructureDefinition/Patient" alias PatientOut as target + + group tutorial(source src : Patient, target tgt : PatientOut) { + src.name as srcName -> tgt.name as tgtName then { + srcName.given as given -> tgtName.given = given; + srcName.family as family -> tgtName.family = family; + }; + } + `; + + const result = compiler.validateSyntax(validFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(true); + expect(result.errors).toBeUndefined(); + }); + }); + + describe('Invalid FML Content', () => { + test('should detect empty content', () => { + const result = compiler.validateSyntax(''); + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors![0].message).toBe('FML content cannot be empty'); + expect(result.errors![0].code).toBe('EMPTY_CONTENT'); + }); + + test('should detect missing map keyword', () => { + const invalidFmlContent = ` + invalid "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight) { + src.a as a -> tgt.a = a; + } + `; + + const result = compiler.validateSyntax(invalidFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors!.some(e => e.code === 'MISSING_MAP_KEYWORD')).toBe(true); + }); + + test('should detect unmatched braces', () => { + const invalidFmlContent = ` + map "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight) { + src.a as a -> tgt.a = a; + + `; + + const result = compiler.validateSyntax(invalidFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors!.some(e => e.code === 'UNCLOSED_BRACE')).toBe(true); + }); + + test('should detect unmatched parentheses', () => { + const invalidFmlContent = ` + map "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight { + src.a as a -> tgt.a = a; + } + `; + + const result = compiler.validateSyntax(invalidFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors!.some(e => e.code === 'UNCLOSED_PAREN')).toBe(true); + }); + + test('should detect extra closing braces', () => { + const invalidFmlContent = ` + map "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight) { + src.a as a -> tgt.a = a; + }} + `; + + const result = compiler.validateSyntax(invalidFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors!.some(e => e.code === 'UNMATCHED_BRACE')).toBe(true); + }); + + test('should handle syntax errors gracefully', () => { + const invalidFmlContent = ` + invalid syntax here + map without proper structure + missing quotes and format + `; + + const result = compiler.validateSyntax(invalidFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors!.length).toBeGreaterThan(0); + }); + }); + + describe('Detailed Error Information', () => { + test('should provide line and column information when available', () => { + const invalidFmlContent = `map "test" = test + + group test(source src, target tgt) { + src.a -> tgt.b; + }}`; + + const result = compiler.validateSyntax(invalidFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + + const unMatchedError = result.errors!.find(e => e.code === 'UNMATCHED_BRACE'); + expect(unMatchedError).toBeDefined(); + expect(unMatchedError!.line).toBeDefined(); + expect(unMatchedError!.column).toBeDefined(); + }); + + test('should return both errors and warnings when present', () => { + const problematicFmlContent = ` + map "http://example.org/tutorial" = tutorial + + group tutorial(source src : TutorialLeft, target tgt : TutorialRight) { + src.a as a -> tgt.a = a; + // Missing closing brace - should generate error + `; + + const result = compiler.validateSyntax(problematicFmlContent); + expect(result.success).toBe(true); + expect(result.isValid).toBe(false); + expect(result.errors).toBeDefined(); + expect(result.errors!.length).toBeGreaterThan(0); + expect(result.errors!.some(e => e.code === 'UNCLOSED_BRACE')).toBe(true); + }); + }); +}); \ No newline at end of file