diff --git a/packages/markdown-template/src/TemplateMarkTransformer.test.ts b/packages/markdown-template/src/TemplateMarkTransformer.test.ts index 5e6aaed4..199fa627 100644 --- a/packages/markdown-template/src/TemplateMarkTransformer.test.ts +++ b/packages/markdown-template/src/TemplateMarkTransformer.test.ts @@ -23,6 +23,20 @@ concept Thing { o String[] items }`; +const SCOPE_MODEL = ` +namespace test@1.0.0 +concept Address { + o String city +} +@template +concept TemplateData { + o String name + o Integer age optional + o String middleName optional + o Boolean isActive optional + o Address address optional +}`; + describe('#TemplateMarkTransformer', () => { describe('#tokensToMarkdownTemplate', () => { it('should handle join with type, style and locale', () => { @@ -82,4 +96,59 @@ describe('#TemplateMarkTransformer', () => { expect(joinNode.foo).toBeUndefined(); }); }); + + describe('#variables inside conditional and optional blocks', () => { + const parse = (content: string) => { + const transformer = new TemplateMarkTransformer(); + const modelManager = new ModelManager(); + modelManager.addCTOModel(SCOPE_MODEL); + return transformer.fromMarkdownTemplate({ content }, modelManager, 'clause'); + }; + const variables = (node: any): any[] => { + const own = node.$class === `${TemplateMarkModel.NAMESPACE}.VariableDefinition` ? [node] : []; + const children = ['nodes', 'whenTrue', 'whenFalse', 'whenSome', 'whenNone'] + .flatMap((key) => node[key] || []) + .flatMap(variables); + return [...own, ...children]; + }; + const elementTypes = (content: string) => + Object.fromEntries(variables(parse(content)).map((v) => [v.name, v.elementType])); + + it.each([ + ['the condition property in #if', '{{#if age}}You are {{age}}.{{/if}}'], + ['other properties in #if', '{{#if isActive}}Hello {{name}}, you are {{age}}.{{/if}}'], + ['properties in the else branch of #if', '{{#if isActive}}Hello {{name}}.{{else}}Bye {{name}}.{{/if}}'], + ['the optional property in #optional', '{{#optional middleName}}Middle {{middleName}}.{{else}}None.{{/optional}}'], + ['other properties in #optional', '{{#optional age}}{{name}} is {{age}}.{{/optional}}'], + ['properties in the else branch of #optional', '{{#optional age}}{{age}}.{{else}}Active: {{isActive}}.{{/optional}}'], + ])('should resolve %s against the parent model', (_, content) => { + expect(() => parse(content)).not.toThrow(); + }); + + it.each([ + ['#if', '{{#if age}}You are {{unknown}}.{{/if}}'], + ['the else branch of #if', '{{#if isActive}}Hi.{{else}}Bye {{unknown}}.{{/if}}'], + ['#optional', '{{#optional age}}You are {{unknown}}.{{/optional}}'], + ['the else branch of #optional', '{{#optional age}}{{age}}.{{else}}{{unknown}}.{{/optional}}'], + ])('should reject unknown properties in %s', (_, content) => { + expect(() => parse(content)).toThrow('Unknown property: unknown'); + }); + + it('should type named variables inside #if by their own property', () => { + expect(elementTypes('{{#if isActive}}{{name}} is {{age}}.{{/if}}')).toEqual({ + name: 'String', + age: 'Integer', + }); + }); + + it('should resolve {{this}} to the guarded property', () => { + expect(elementTypes('{{#if age}}Age {{this}}.{{/if}}')).toEqual({ this: 'Integer' }); + expect(elementTypes('{{#optional middleName}}Middle {{this}}.{{/optional}}')).toEqual({ this: 'String' }); + }); + + it('should scope complex #optional blocks to the nested class', () => { + expect(elementTypes('{{#optional address}}Lives in {{city}}.{{/optional}}')).toEqual({ city: 'String' }); + expect(() => parse('{{#optional address}}{{name}}{{/optional}}')).toThrow('Unknown property: name'); + }); + }); }); diff --git a/packages/markdown-template/src/TypeVisitor.ts b/packages/markdown-template/src/TypeVisitor.ts index 9ee8d718..bb172f33 100644 --- a/packages/markdown-template/src/TypeVisitor.ts +++ b/packages/markdown-template/src/TypeVisitor.ts @@ -103,7 +103,9 @@ export class TypeVisitor { _throwTemplateExceptionForElement('Unknown property: ' + thing.name, thing); } if (thing.name === 'this') { - const property = currentModel; + // Conditional and primitive optional blocks keep the parent scope, + // so {{this}} resolves to the guarded property instead + const property = parameters.thisProperty || currentModel; if (property && property.getType) { const serializer = parameters.templateMarkModelManager.getSerializer(); @@ -246,24 +248,24 @@ export class TypeVisitor { const serializer = parameters.templateMarkModelManager.getSerializer(); thing.decorators = property ? processDecorators(serializer, property) : null; - const nextModel = property; + // Conditional blocks are guards and do not change scope TypeVisitor.visitChildren(this, thing, { templateMarkModelManager: parameters.templateMarkModelManager, introspector: parameters.introspector, - model: nextModel, + model: currentModel, + thisProperty: property, kind: parameters.kind, }, 'whenTrue'); TypeVisitor.visitChildren(this, thing, { templateMarkModelManager: parameters.templateMarkModelManager, introspector: parameters.introspector, - model: null, + model: currentModel, kind: parameters.kind, }, 'whenFalse'); break; } case 'OptionalDefinition': { const property = currentModel.getOwnProperty(thing.name); - let nextModel; if (!property) { _throwTemplateExceptionForElement('Unknown property: ' + thing.name, thing); } @@ -272,23 +274,20 @@ export class TypeVisitor { } const serializer = parameters.templateMarkModelManager.getSerializer(); thing.decorators = processDecorators(serializer, property); - if (property.isPrimitive()) { - thing.elementType = property.getFullyQualifiedTypeName(); - nextModel = property; - } else { - thing.elementType = property.getFullyQualifiedTypeName(); - nextModel = parameters.introspector.getClassDeclaration(thing.elementType); - } + thing.elementType = property.getFullyQualifiedTypeName(); + // A primitive optional keeps the parent scope; a complex one scopes to its class + const nextModel = property.isPrimitive() ? currentModel : parameters.introspector.getClassDeclaration(thing.elementType); TypeVisitor.visitChildren(this, thing, { templateMarkModelManager: parameters.templateMarkModelManager, introspector: parameters.introspector, model: nextModel, + thisProperty: property.isPrimitive() ? property : null, kind: parameters.kind, }, 'whenSome'); TypeVisitor.visitChildren(this, thing, { templateMarkModelManager: parameters.templateMarkModelManager, introspector: parameters.introspector, - model: null, + model: currentModel, kind: parameters.kind, }, 'whenNone'); break;