UNPKG

18.7 kBJavaScriptView Raw
1/*
2 * Licensed under the Apache License, Version 2.0 (the "License");
3 * you may not use this file except in compliance with the License.
4 * You may obtain a copy of the License at
5 *
6 * http://www.apache.org/licenses/LICENSE-2.0
7 *
8 * Unless required by applicable law or agreed to in writing, software
9 * distributed under the License is distributed on an "AS IS" BASIS,
10 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11 * See the License for the specific language governing permissions and
12 * limitations under the License.
13 */
14'use strict';
15
16const fsPath = require('path');
17
18const TemplateException = require('./templateexception');
19
20const RelationshipDeclaration = require('@accordproject/concerto-core').RelationshipDeclaration;
21
22const Writer = require('@accordproject/concerto-core').Writer;
23
24const Logger = require('@accordproject/concerto-core').Logger;
25
26const nearley = require('nearley');
27
28const compile = require('nearley/lib/compile');
29
30const generate = require('nearley/lib/generate');
31
32const nearleyGrammar = require('nearley/lib/nearley-language-bootstrapped');
33
34const templateGrammar = require('./tdl.js');
35
36const GrammarVisitor = require('./grammarvisitor');
37
38const uuid = require('uuid');
39
40const nunjucks = require('nunjucks');
41
42const DateTimeFormatParser = require('./datetimeformatparser');
43
44const CommonMarkTransformer = require('@accordproject/markdown-common').CommonMarkTransformer; // This required because only compiled nunjucks templates are supported browser-side
45// https://mozilla.github.io/nunjucks/api.html#browser-usage
46// We can't always import it in Cicero because precompiling is not supported server-side!
47// https://github.com/mozilla/nunjucks/issues/1065
48
49
50if (process.browser) {
51 require('./compiled_template');
52}
53/**
54 * Generates and manages a Nearley parser for a template.
55 * @class
56 */
57
58
59class ParserManager {
60 /**
61 * Create the ParserManager.
62 * @param {object} template - the template instance
63 */
64 constructor(template) {
65 this.template = template;
66 this.grammar = null;
67 this.grammarAst = null;
68 this.templatizedGrammar = null;
69 this.templateAst = null;
70 }
71 /**
72 * Gets a parser object for this template
73 * @return {object} the parser for this template
74 */
75
76
77 getParser() {
78 if (!this.grammarAst) {
79 throw new Error('Must call setGrammar or buildGrammar before calling getParser');
80 }
81
82 return new nearley.Parser(nearley.Grammar.fromCompiled(this.grammarAst));
83 }
84 /**
85 * Gets the AST for the template
86 * @return {object} the AST for the template
87 */
88
89
90 getTemplateAst() {
91 if (!this.grammarAst) {
92 throw new Error('Must call setGrammar or buildGrammar before calling getTemplateAst');
93 }
94
95 return this.templateAst;
96 }
97 /**
98 * Set the grammar for the template
99 * @param {String} grammar - the grammar for the template
100 */
101
102
103 setGrammar(grammar) {
104 this.grammarAst = ParserManager.compileGrammar(grammar);
105 this.grammar = grammar;
106 }
107 /**
108 * Adjust the template for list blocks
109 * @param {object} x - The current template AST node
110 * @param {String} separator - The list separator
111 * @return {object} the new template AST node
112 */
113
114
115 static adjustListBlock(x, separator) {
116 if (x.data[0] && x.data[0].type === 'Chunk') {
117 x.data[0].value = separator + x.data[0].value;
118 return x;
119 } else {
120 throw new Error('List block in template should contain text');
121 }
122 }
123 /**
124 * Build a grammar from a template
125 * @param {String} templatizedGrammar - the annotated template
126 * using the markdown parser
127 */
128
129
130 buildGrammar(templatizedGrammar) {
131 // Roundtrip the grammar through the Commonmark parser
132 templatizedGrammar = this.roundtripMarkdown(templatizedGrammar); // console.log(templatizedGrammar);
133
134 Logger.debug('buildGrammar', templatizedGrammar);
135 const parser = new nearley.Parser(nearley.Grammar.fromCompiled(templateGrammar));
136 parser.feed(templatizedGrammar);
137
138 if (parser.results.length !== 1) {
139 throw new Error('Ambiguous parse!');
140 } // parse the template grammar to generate a dynamic grammar
141
142
143 const ast = parser.results[0];
144 this.templateAst = ast;
145 const parts = {
146 textRules: [],
147 modelRules: [],
148 grammars: {}
149 };
150 parts.grammars.base = require('./grammars/base');
151 this.buildGrammarRules(ast, this.template.getTemplateModel(), 'rule', parts); // generate the grammar for the model
152
153 const parameters = {
154 writer: new Writer(),
155 rules: []
156 };
157 const gv = new GrammarVisitor();
158 this.template.getModelManager().accept(gv, parameters);
159 parts.modelRules.push(...parameters.rules); // combine the results
160
161 nunjucks.configure(fsPath.resolve(__dirname), {
162 tags: {
163 blockStart: '<%',
164 blockEnd: '%>'
165 },
166 autoescape: false // Required to allow nearley syntax strings
167
168 });
169 const combined = nunjucks.render('template.ne', parts);
170 Logger.debug('Generated template grammar' + combined); // console.log(combined);
171
172 this.setGrammar(combined);
173 this.templatizedGrammar = templatizedGrammar;
174 }
175 /**
176 * Build grammar rules from a template
177 * @param {object} ast - the AST from which to build the grammar
178 * @param {ClassDeclaration} templateModel - the type of the parent class for this AST
179 * @param {String} prefix - A unique prefix for the grammar rules
180 * @param {Object} parts - Result object to acculumate rules and required sub-grammars
181 */
182
183
184 buildGrammarRules(ast, templateModel, prefix, parts) {
185 // these are the rules for variables
186 const rules = {}; // these are the rules for static text
187
188 let textRules = {}; // generate all the rules for the static text
189
190 textRules.prefix = prefix;
191 textRules.symbols = [];
192 ast.data.forEach((element, index) => {
193 // ignore empty chunks (issue #1) and missing optional last chunks
194 if (element && (element.type !== 'Chunk' || element.value.length > 0)) {
195 Logger.debug("element ".concat(prefix).concat(index, " ").concat(JSON.stringify(element)));
196 rules[prefix + index] = element;
197 textRules.symbols.push(prefix + index);
198 }
199 }, this); // the result of parsing is an instance of the template model
200
201 textRules.class = templateModel.getFullyQualifiedName();
202 const identifier = templateModel.getIdentifierFieldName();
203
204 if (identifier !== null) {
205 textRules.identifier = "".concat(identifier, " : \"").concat(uuid.v4(), "\"");
206 } // we then bind each variable in the template model
207 // to the first occurence of the variable in the template grammar
208
209
210 textRules.properties = [];
211 templateModel.getProperties().forEach((property, index) => {
212 const sep = index < templateModel.getProperties().length - 1 ? ',' : '';
213 const bindingIndex = this.findFirstBinding(property.getName(), ast.data);
214
215 if (bindingIndex !== -1) {
216 // ignore things like transactionId
217 textRules.properties.push("".concat(property.getName(), " : ").concat(prefix).concat(bindingIndex).concat(sep));
218 }
219 });
220 parts.textRules.push(textRules); // Now create the child rules for each symbol in the root rule
221
222 for (let rule in rules) {
223 const element = rules[rule];
224
225 switch (element.type) {
226 case 'Chunk':
227 case 'ExprChunk':
228 case 'LastChunk':
229 parts.modelRules.push({
230 prefix: rule,
231 symbols: [this.cleanChunk(element.value)]
232 });
233 break;
234
235 case 'IfBinding':
236 {
237 const property = ParserManager.getProperty(templateModel, element);
238
239 if (property.getType() !== 'Boolean') {
240 ParserManager._throwTemplateExceptionForElement("An if block can only be used with a boolean property. Property ".concat(element.fieldName.value, " has type ").concat(property.getType()), element);
241 }
242
243 parts.modelRules.push({
244 prefix: rule,
245 symbols: ["\"".concat(element.stringIf.value, "\":? {% (d) => {return d[0] !== null;}%} # ").concat(element.fieldName.value)]
246 });
247 }
248 break;
249
250 case 'IfElseBinding':
251 {
252 const property = ParserManager.getProperty(templateModel, element);
253
254 if (property.getType() !== 'Boolean') {
255 ParserManager._throwTemplateExceptionForElement("An if block can only be used with a boolean property. Property ".concat(element.fieldName.value, " has type ").concat(property.getType()), element);
256 }
257
258 parts.modelRules.push({
259 prefix: rule,
260 symbols: ["(\"".concat(element.stringIf.value, "\"|\"").concat(element.stringElse.value, "\") {% (d) => {return d[0][0] === \"").concat(element.stringIf.value, "\";}%} # ").concat(element.fieldName.value)]
261 });
262 }
263 break;
264
265 case 'FormattedBinding':
266 case 'Binding':
267 case 'ClauseBinding':
268 case 'WithBinding':
269 case 'UListBinding':
270 case 'OListBinding':
271 case 'JoinBinding':
272 this.handleBinding(templateModel, parts, rule, element);
273 break;
274
275 case 'Expr':
276 parts.modelRules.push({
277 prefix: rule,
278 symbols: ['Any']
279 });
280 break;
281
282 default:
283 ParserManager._throwTemplateExceptionForElement("Unrecognized type ".concat(element.type), element);
284
285 }
286 }
287 }
288 /**
289 * Throws an error if a template variable doesn't exist on the model.
290 * @param {*} templateModel - the model for the template
291 * @param {*} element - the current element in the AST
292 * @returns {*} the property
293 */
294
295
296 static getProperty(templateModel, element) {
297 const propertyName = element.fieldName.value;
298 const property = templateModel.getProperty(propertyName);
299
300 if (!property) {
301 ParserManager._throwTemplateExceptionForElement("Template references a property '".concat(propertyName, "' that is not declared in the template model '").concat(templateModel.getFullyQualifiedName(), "'"), element);
302 }
303
304 return property;
305 }
306 /**
307 * Throw a template exception for the element
308 * @param {string} message - the error message
309 * @param {object} element the AST
310 * @throws {TemplateException}
311 */
312
313
314 static _throwTemplateExceptionForElement(message, element) {
315 const fileName = 'text/grammar.tem.md';
316 let column = element.fieldName.col;
317 let line = element.fieldName.line;
318 let token = element.value ? element.value : ' ';
319 const endColumn = column + token.length;
320 const fileLocation = {
321 start: {
322 line,
323 column
324 },
325 end: {
326 line,
327 endColumn //XXX
328
329 }
330 };
331 throw new TemplateException(message, fileLocation, fileName, null, 'cicero-core');
332 }
333 /**
334 * Utility method to generate a grammar rule for a variable binding
335 * @param {ClassDeclaration} templateModel - the current template model
336 * @param {*} parts - the parts, where the rule will be added
337 * @param {*} inputRule - the rule we are processing in the AST
338 * @param {*} element - the current element in the AST
339 */
340
341
342 handleBinding(templateModel, parts, inputRule, element) {
343 const propertyName = element.fieldName.value;
344 const property = ParserManager.getProperty(templateModel, element);
345 let action = null;
346 let suffix = ':';
347 let type = property.getType();
348 let firstType = null; // if the type/action have not been set explicity, then we infer them
349
350 if (!action) {
351 action = '{% id %}';
352
353 if (property.getType() === 'DateTime' || element.type === 'FormattedBinding') {
354 if (property.getType() !== 'DateTime') {
355 ParserManager._throwTemplateExceptionForElement('Formatted types are currently only supported for DateTime properties.', element);
356 } // we only include the datetime grammar if custom formats are used
357
358
359 if (!parts.grammars.dateTime) {
360 parts.grammars.dateTime = require('./grammars/datetime');
361 parts.grammars.dateTimeEn = require('./grammars/datetime-en');
362 } // push the formatting rule, iff it has not been already declared
363
364
365 const format = element.format ? element.format.value : '"MM/DD/YYYY"';
366 const formatRule = DateTimeFormatParser.buildDateTimeFormatRule(format);
367 type = formatRule.name;
368 const ruleExists = parts.modelRules.some(rule => rule.prefix === formatRule.name);
369
370 if (!ruleExists) {
371 parts.modelRules.push({
372 prefix: formatRule.name,
373 symbols: ["".concat(formatRule.tokens, " ").concat(formatRule.action, " # ").concat(propertyName, " as ").concat(format)]
374 });
375 }
376 } else if (element.type === 'ClauseBinding' || element.type === 'WithBinding') {
377 const nestedTemplate = element.template;
378 const nestedTemplateModel = this.template.getIntrospector().getClassDeclaration(property.getFullyQualifiedTypeName());
379 this.buildGrammarRules(nestedTemplate, nestedTemplateModel, propertyName, parts);
380 type = element.fieldName.value;
381 } else if (element.type === 'UListBinding' || element.type === 'OListBinding' || element.type === 'JoinBinding') {
382 const nestedTemplateModel = this.template.getIntrospector().getClassDeclaration(property.getFullyQualifiedTypeName()); // What separates elements in the list?
383
384 let separator;
385
386 if (element.type === 'JoinBinding') {
387 separator = element.separator;
388 } else {
389 separator = element.type === 'UListBinding' ? '- ' : '1. ';
390 } // Rule for first item in the list
391
392
393 let firstNestedTemplate;
394
395 if (element.type === 'JoinBinding') {
396 firstNestedTemplate = element.template;
397 } else {
398 firstNestedTemplate = ParserManager.adjustListBlock(element.template, separator);
399 }
400
401 this.buildGrammarRules(firstNestedTemplate, nestedTemplateModel, propertyName + 'First', parts);
402 firstType = element.fieldName.value + 'First'; // Rule for all other items in the list
403
404 let nestedTemplate;
405
406 if (element.type === 'JoinBinding') {
407 nestedTemplate = ParserManager.adjustListBlock(element.template, separator);
408 } else {
409 nestedTemplate = ParserManager.adjustListBlock(element.template, '\n');
410 }
411
412 this.buildGrammarRules(nestedTemplate, nestedTemplateModel, propertyName, parts);
413 type = element.fieldName.value;
414 action = "\n{%\n ([ ".concat(propertyName + 'First', ", ").concat(propertyName, " ]) => {\n return [").concat(propertyName + 'First', "].concat(").concat(propertyName, ");\n}\n%}");
415 } else {
416 // relationships need to be transformed into strings
417 if (property instanceof RelationshipDeclaration) {
418 type = 'String';
419 }
420 }
421 }
422
423 if (property.isArray()) {
424 suffix += '*';
425 }
426
427 if (property.isOptional()) {
428 suffix += '?';
429 }
430
431 if (suffix === ':') {
432 suffix = '';
433 } // console.log(`${inputRule} => ${type}${suffix} ${action} # ${propertyName}`);
434
435
436 if (element.type === 'UListBinding' || element.type === 'OListBinding' || element.type === 'JoinBinding') {
437 parts.modelRules.push({
438 prefix: inputRule,
439 //symbols: [`"[{" ${type}${suffix} "}]" ${action} # ${propertyName}`],
440 symbols: ["".concat(firstType, " ").concat(type).concat(suffix, " ").concat(action, " # ").concat(propertyName)]
441 });
442 } else {
443 parts.modelRules.push({
444 prefix: inputRule,
445 //symbols: [`"[{" ${type}${suffix} "}]" ${action} # ${propertyName}`],
446 symbols: ["".concat(type).concat(suffix, " ").concat(action, " # ").concat(propertyName)]
447 });
448 }
449 }
450 /**
451 * Cleans a chunk of text to make it safe to include
452 * as a grammar rule. We need to remove linefeeds and
453 * escape any '"' characters.
454 *
455 * @param {string} input - the input text from the template
456 * @return {string} cleaned text
457 */
458
459
460 cleanChunk(input) {
461 // we replace all \n with \\n
462 let text = input.replace(/\n/gm, '\\n'); // replace all " with \"
463
464 text = text.replace(/"/gm, '\\"');
465 return "\"".concat(text, "\"");
466 }
467 /**
468 * Finds the first binding for the given property
469 *
470 * @param {string} propertyName the name of the property
471 * @param {object[]} elements the result of parsing the template_txt.
472 * @return {int} the index of the element or -1
473 */
474
475
476 findFirstBinding(propertyName, elements) {
477 for (let n = 0; n < elements.length; n++) {
478 const element = elements[n];
479
480 if (element !== null && ['Binding', 'FormattedBinding', 'IfBinding', 'IfElseBinding', 'UListBinding', 'OListBinding', 'JoinBinding', 'ClauseBinding', 'WithBinding'].includes(element.type)) {
481 if (element.fieldName.value === propertyName) {
482 return n;
483 }
484 }
485 }
486
487 return -1;
488 }
489 /**
490 * Get the (compiled) grammar for the template
491 * @return {String} - the grammar for the template
492 */
493
494
495 getGrammar() {
496 return this.grammar;
497 }
498 /**
499 * Returns the templatized grammar
500 * @return {String} the contents of the templatized grammar
501 */
502
503
504 getTemplatizedGrammar() {
505 return this.templatizedGrammar;
506 }
507 /**
508 * Compiles a Nearley grammar to its AST
509 * @param {string} sourceCode - the source text for the grammar
510 * @return {object} the AST for the grammar
511 */
512
513
514 static compileGrammar(sourceCode) {
515 try {
516 // Parse the grammar source into an AST
517 const grammarParser = new nearley.Parser(nearleyGrammar);
518 grammarParser.feed(sourceCode);
519 const grammarAst = grammarParser.results[0]; // TODO check for errors
520 // Compile the AST into a set of rules
521
522 const grammarInfoObject = compile(grammarAst, {}); // Generate JavaScript code from the rules
523
524 const grammarJs = generate(grammarInfoObject, 'grammar'); // Pretend this is a CommonJS environment to catch exports from the grammar.
525
526 const module = {
527 exports: {}
528 };
529 eval(grammarJs);
530 return module.exports;
531 } catch (err) {
532 Logger.error(err);
533 throw err;
534 }
535 }
536 /**
537 * Round-trip markdown
538 * @param {string} text - the markdown text
539 * @return {string} the result of parsing and printing back the text
540 */
541
542
543 roundtripMarkdown(text) {
544 // Roundtrip the grammar through the Commonmark parser
545 const commonMarkTransformer = new CommonMarkTransformer({
546 noIndex: true
547 });
548 const concertoAst = commonMarkTransformer.fromMarkdown(text);
549 return commonMarkTransformer.toMarkdown(concertoAst);
550 }
551
552}
553
554module.exports = ParserManager;
\No newline at end of file