Repository: FHIR/sushi
Pull request #701 (2020-12-21 15:48:18)

Export Parameterized RuleSet
Completes task [CIMPL-574](https://standardhealthrecord.atlassian.net/browse/CIMPL-574).

These changes accompany #687 so that insert rules with parameters are applied correctly during the export process. So, the key function being updated is `applyInsertRules` in `fhirtypes/common.ts`. The logic for fishing up a non-parameterized RuleSet  and an application of a parameterized RuleSet are different: a non-parameterized RuleSet can be found by the `name` attribute on the RuleSet, but an application of a parameterized RuleSet must be found by using the name and parameters to form a valid key for the `FSHDocument`'s `appliedRuleSets` map. Thus, a separate fishing function is used when fishing for an applied parameterized RuleSet. Similarly, the logic for detecting circular RuleSet references is updated to track by a combination of RuleSet name and parameter values.

Once the RuleSet is fished up, the logic for applying its contents is unchanged.


Patch:
diff --git a/src/fhirtypes/common.ts b/src/fhirtypes/common.ts
index 28dca8f..29fd3f5 100644
--- a/src/fhirtypes/common.ts
+++ b/src/fhirtypes/common.ts
@@ -433,9 +433,16 @@ export function applyInsertRules(
       expandedRules.push(rule);
       return;
     }
-    const ruleSet = tank.fish(rule.ruleSet, Type.RuleSet) as RuleSet;
+    const ruleSetIdentifier = JSON.stringify([rule.ruleSet, ...rule.params]);
+    let ruleSet: RuleSet;
+    if (rule.params.length) {
+      ruleSet = tank.fishForAppliedRuleSet(ruleSetIdentifier);
+    } else {
+      ruleSet = tank.fish(rule.ruleSet, Type.RuleSet) as RuleSet;
+    }
+
     if (ruleSet) {
-      if (seenRuleSets.includes(ruleSet.name)) {
+      if (seenRuleSets.includes(ruleSetIdentifier)) {
         logger.error(
           `Inserting ${ruleSet.name} will cause a circular dependency, so the rule will be ignored`,
           rule.sourceInfo
@@ -444,7 +451,7 @@ export function applyInsertRules(
       }
       // RuleSets may contain other RuleSets via insert rules on themselves, so before applying the rules
       // from a RuleSet, we must first recursively expand any insert rules on that RuleSet
-      applyInsertRules(ruleSet, tank, [...seenRuleSets, ruleSet.name]);
+      applyInsertRules(ruleSet, tank, [...seenRuleSets, ruleSetIdentifier]);
       ruleSet.rules.forEach(ruleSetRule => {
         // On the import side, a rule that is intended to be a ValueSetConceptComponent can
         // be imported as a ConceptRule because the syntax is identical. If this is the case,
diff --git a/src/import/FSHImporter.ts b/src/import/FSHImporter.ts
index d150f17..e51046b 100644
--- a/src/import/FSHImporter.ts
+++ b/src/import/FSHImporter.ts
@@ -1463,6 +1463,16 @@ export class FSHImporter extends FSHVisitor {
               insertRule
             );
             if (appliedRuleSet) {
+              // set the source info based on the original source info
+              appliedRuleSet.sourceInfo.file = ruleSet.sourceInfo.file;
+              appliedRuleSet.sourceInfo.location = { ...ruleSet.sourceInfo.location };
+              appliedRuleSet.rules.forEach(rule => {
+                rule.sourceInfo.file = appliedRuleSet.sourceInfo.file;
+                rule.sourceInfo.location.startLine +=
+                  appliedRuleSet.sourceInfo.location.startLine - 1;
+                rule.sourceInfo.location.endLine +=
+                  appliedRuleSet.sourceInfo.location.startLine - 1;
+              });
               this.currentDoc.appliedRuleSets.set(ruleSetIdentifier, appliedRuleSet);
               return insertRule;
             } else {
diff --git a/src/import/FSHTank.ts b/src/import/FSHTank.ts
index bc323d8..097452c 100644
--- a/src/import/FSHTank.ts
+++ b/src/import/FSHTank.ts
@@ -193,6 +193,15 @@ export class FSHTank implements Fishable {
     return;
   }
 
+  fishForAppliedRuleSet(item: string): RuleSet | undefined {
+    for (const doc of this.docs) {
+      const ruleSet = doc.appliedRuleSets.get(item);
+      if (ruleSet) {
+        return ruleSet;
+      }
+    }
+  }
+
   fishForMetadata(item: string, ...types: Type[]): Metadata | undefined {
     const result = this.fish(item, ...types);
     if (result) {


Test patch:
diff --git a/test/fhirtypes/applyInsertRules.test.ts b/test/fhirtypes/applyInsertRules.test.ts
index 230272c..ca8e131 100644
--- a/test/fhirtypes/applyInsertRules.test.ts
+++ b/test/fhirtypes/applyInsertRules.test.ts
@@ -1,7 +1,12 @@
 import { FSHTank, FSHDocument } from '../../src/import';
 import { Profile, RuleSet, FshCode, FshValueSet } from '../../src/fshtypes';
-import { InsertRule, CardRule, ConceptRule } from '../../src/fshtypes/rules';
-import { loggerSpy, assertCardRule, assertValueSetConceptComponent } from '../testhelpers';
+import { InsertRule, CardRule, ConceptRule, AssignmentRule } from '../../src/fshtypes/rules';
+import {
+  loggerSpy,
+  assertCardRule,
+  assertValueSetConceptComponent,
+  assertAssignmentRule
+} from '../testhelpers';
 import { minimalConfig } from '../utils/minimalConfig';
 import { applyInsertRules } from '../../src/fhirtypes/common';
 
@@ -366,4 +371,88 @@ describe('applyInsertRules', () => {
       /Unable to find definition for RuleSet Bam.*File: NoBam\.fsh.*Line: 1 - 3\D*/s
     );
   });
+
+  describe('#appliedRuleSet', () => {
+    it('should apply rules from a single level insert rule with parameters', () => {
+      // RuleSet: SimpleRules (value, maxNote)
+      // valueString = {value}
+      // note 0..{maxNote}
+      //
+      // Profile: Foo
+      // Parent: Observation
+      // * insert SimpleRules ("Something", 5)
+      const appliedSimpleRules = new RuleSet('SimpleRules');
+      const valueRule = new AssignmentRule('valueString');
+      valueRule.value = 'Something';
+      valueRule.exactly = false;
+      valueRule.isInstance = false;
+      const noteRule = new CardRule('note');
+      noteRule.min = 0;
+      noteRule.max = '5';
+      appliedSimpleRules.rules.push(valueRule, noteRule);
+      doc.appliedRuleSets.set(
+        JSON.stringify(['SimpleRules', '"Something"', '5']),
+        appliedSimpleRules
+      );
+
+      const insertRule = new InsertRule();
+      insertRule.ruleSet = 'SimpleRules';
+      insertRule.params = ['"Something"', '5'];
+      profile.rules.push(insertRule);
+
+      applyInsertRules(profile, tank);
+      expect(profile.rules).toHaveLength(2);
+      assertAssignmentRule(profile.rules[0], 'valueString', 'Something', false, false);
+      assertCardRule(profile.rules[1], 'note', 0, 5);
+    });
+
+    it('should ignore insert rules with parameters that cause a circular dependency, and log an error', () => {
+      // RuleSet: SimpleRules (maxNote)
+      // note 0..{maxNote}
+      // insert FancyRules ({maxNote})
+      //
+      // RuleSet: FancyRules (maxInterpretation)
+      // interpretation 0..{maxInterpretation}
+      // insert SimpleRules ({maxInterpretation})
+      //
+      // Profile: Foo
+      // Parent: Observation
+      // * insert SimpleRules (5)
+      const appliedSimpleRules = new RuleSet('SimpleRules');
+      const simpleCard = new CardRule('note');
+      simpleCard.min = 0;
+      simpleCard.max = '5';
+      const insertFancy = new InsertRule();
+      insertFancy.ruleSet = 'FancyRules';
+      insertFancy.params = ['5'];
+      appliedSimpleRules.rules.push(simpleCard, insertFancy);
+
+      const appliedFancyRules = new RuleSet('FancyRules');
+      const fancyCard = new CardRule('interpretation');
+      fancyCard.min = 0;
+      fancyCard.max = '5';
+      const insertSimple = new InsertRule().withFile('Fancy.fsh').withLocation([4, 9, 4, 29]);
+      insertSimple.ruleSet = 'SimpleRules';
+      insertSimple.params = ['5'];
+      appliedFancyRules.rules.push(fancyCard, insertSimple);
+
+      doc.appliedRuleSets
+        .set(JSON.stringify(['SimpleRules', '5']), appliedSimpleRules)
+        .set(JSON.stringify(['FancyRules', '5']), appliedFancyRules);
+
+      const insertRule = new InsertRule();
+      insertRule.ruleSet = 'SimpleRules';
+      insertRule.params = ['5'];
+      profile.rules.push(insertRule);
+
+      applyInsertRules(profile, tank);
+      expect(profile.rules).toHaveLength(2);
+      assertCardRule(profile.rules[0], 'note', 0, 5);
+      assertCardRule(profile.rules[1], 'interpretation', 0, 5);
+      expect(loggerSpy.getLastMessage('error')).toMatch(
+        /SimpleRules will cause a circular dependency/s
+      );
+      expect(loggerSpy.getLastMessage('error')).toMatch(/File: Fancy\.fsh.*Line: 4\D*/s);
+    });
+  });
 });
diff --git a/test/import/FSHImporter.Profile.test.ts b/test/import/FSHImporter.Profile.test.ts
index 578e66d..5fc5d87 100644
--- a/test/import/FSHImporter.Profile.test.ts
+++ b/test/import/FSHImporter.Profile.test.ts
@@ -1851,7 +1851,9 @@ describe('FSHImporter', () => {
         importer = new FSHImporter();
         // RuleSet: OneParamRuleSet (val)
         // * status = {val}
-        const oneParamRuleSet = new ParamRuleSet('OneParamRuleSet');
+        const oneParamRuleSet = new ParamRuleSet('OneParamRuleSet')
+          .withFile('RuleSet.fsh')
+          .withLocation([1, 12, 2, 27]);
         oneParamRuleSet.parameters = ['val'];
         oneParamRuleSet.contents = '* status = {val}';
         importer.paramRuleSets.set(oneParamRuleSet.name, oneParamRuleSet);
@@ -1859,7 +1861,9 @@ describe('FSHImporter', () => {
         // * status = {status}
         // * valueString = {value}
         // * note 0..{maxNote}
-        const multiParamRuleSet = new ParamRuleSet('MultiParamRuleSet');
+        const multiParamRuleSet = new ParamRuleSet('MultiParamRuleSet')
+          .withFile('RuleSet.fsh')
+          .withLocation([4, 12, 7, 30]);
         multiParamRuleSet.parameters = ['status', 'value', 'maxNote'];
         multiParamRuleSet.contents = [
           '* status = {status}',
@@ -1869,14 +1873,18 @@ describe('FSHImporter', () => {
         importer.paramRuleSets.set(multiParamRuleSet.name, multiParamRuleSet);
         // RuleSet: EntryRules (continuation)
         // * insert {continuation}Rules (5)
-        const entryRules = new ParamRuleSet('EntryRules');
+        const entryRules = new ParamRuleSet('EntryRules')
+          .withFile('RuleSet.fsh')
+          .withLocation([9, 12, 10, 43]);
         entryRules.parameters = ['continuation'];
         entryRules.contents = '* insert {continuation}Rules (5)';
         importer.paramRuleSets.set(entryRules.name, entryRules);
         // RuleSet: RecursiveRules (value)
         // * interpretation 0..{value}
         // * insert EntryRules (BaseCase)
-        const recursiveRules = new ParamRuleSet('RecursiveRules');
+        const recursiveRules = new ParamRuleSet('RecursiveRules')
+          .withFile('RuleSet.fsh')
+          .withLocation([12, 12, 14, 41]);
         recursiveRules.parameters = ['value'];
         recursiveRules.contents = [
           '* interpretation 0..{value}',
@@ -1885,21 +1893,27 @@ describe('FSHImporter', () => {
         importer.paramRuleSets.set(recursiveRules.name, recursiveRules);
         // RuleSet: BaseCaseRules (value)
         // * note 0..{value}
-        const baseCaseRules = new ParamRuleSet('BaseCaseRules');
+        const baseCaseRules = new ParamRuleSet('BaseCaseRules')
+          .withFile('RuleSet.fsh')
+          .withLocation([16, 12, 17, 28]);
         baseCaseRules.parameters = ['value'];
         baseCaseRules.contents = '* note 0..{value}';
         importer.paramRuleSets.set(baseCaseRules.name, baseCaseRules);
         // RuleSet: CardRuleSet (path, min, max)
         // * {path} {min}..{max}
         // * note {min}..{max}
-        const cardRuleSet = new ParamRuleSet('CardRuleSet').withFile('RuleSet.fsh');
+        const cardRuleSet = new ParamRuleSet('CardRuleSet')
+          .withFile('RuleSet.fsh')
+          .withLocation([19, 12, 21, 30]);
         cardRuleSet.parameters = ['path', 'min', 'max'];
         cardRuleSet.contents = ['* {path} {min}..{max}', '* note {min}..{max}'].join(EOL);
         importer.paramRuleSets.set(cardRuleSet.name, cardRuleSet);
         // RuleSet: FirstRiskyRuleSet (value)
         // * note ={value}
         // * insert SecondRiskyRuleSet({value})
-        const firstRiskyRuleSet = new ParamRuleSet('FirstRiskyRuleSet').withFile('RuleSet.fsh');
+        const firstRiskyRuleSet = new ParamRuleSet('FirstRiskyRuleSet')
+          .withFile('RuleSet.fsh')
+          .withLocation([23, 12, 25, 47]);
         firstRiskyRuleSet.parameters = ['value'];
         firstRiskyRuleSet.contents = [
           '* note ={value}',
@@ -1908,14 +1922,18 @@ describe('FSHImporter', () => {
         importer.paramRuleSets.set(firstRiskyRuleSet.name, firstRiskyRuleSet);
         // RuleSet: SecondRiskyRuleSet(value)
         // * status ={value}
-        const secondRiskyRuleSet = new ParamRuleSet('SecondRiskyRuleSet').withFile('RuleSet.fsh');
+        const secondRiskyRuleSet = new ParamRuleSet('SecondRiskyRuleSet')
+          .withFile('RuleSet.fsh')
+          .withLocation([27, 12, 28, 28]);
         secondRiskyRuleSet.parameters = ['value'];
         secondRiskyRuleSet.contents = '* status ={value}';
         importer.paramRuleSets.set(secondRiskyRuleSet.name, secondRiskyRuleSet);
         // RuleSet: WarningRuleSet(value)
         // * focus[0] only Reference(Patient | {value})
         // * focus[1] only Reference(Group | {value})
-        const warningRuleSet = new ParamRuleSet('WarningRuleSet');
+        const warningRuleSet = new ParamRuleSet('WarningRuleSet')
+          .withFile('RuleSet.fsh')
+          .withLocation([30, 12, 32, 53]);
         warningRuleSet.parameters = ['value'];
         warningRuleSet.contents = [
           '* focus[0] only Reference(Patient | {value})',
@@ -1949,7 +1967,28 @@ describe('FSHImporter', () => {
         const profile = doc.profiles.get('ObservationProfile');
         expect(profile.rules).toHaveLength(1);
         assertInsertRule(profile.rules[0], 'OneParamRuleSet', ['#final']);
-        expect(doc.appliedRuleSets.has(JSON.stringify(['OneParamRuleSet', '#final']))).toBe(true);
+        const appliedRuleSet = doc.appliedRuleSets.get(
+          JSON.stringify(['OneParamRuleSet', '#final'])
+        );
+        expect(appliedRuleSet).toBeDefined();
+        expect(appliedRuleSet.sourceInfo).toEqual({
+          file: 'RuleSet.fsh',
+          location: {
+            startLine: 1,
+            startColumn: 12,
+            endLine: 2,
+            endColumn: 27
+          }
+        });
+        expect(appliedRuleSet.rules[0].sourceInfo).toEqual({
+          file: 'RuleSet.fsh',
+          location: {
+            startLine: 2,
+            startColumn: 1,
+            endLine: 2,
+            endColumn: 17
+          }
+        });
       });
 
       it('should parse an insert rule with a RuleSet with multiple parameters', () => {
@@ -1978,6 +2017,15 @@ describe('FSHImporter', () => {
           ])
         );
         expect(appliedRuleSet).toBeDefined();
+        expect(appliedRuleSet.sourceInfo).toEqual({
+          file: 'RuleSet.fsh',
+          location: {
+            startLine: 4,
+            startColumn: 12,
+            endLine: 7,
+            endColumn: 30
+          }
+        });
         expect(appliedRuleSet.rules).toHaveLength(3);
         assertAssignmentRule(
           appliedRuleSet.rules[0],
@@ -1986,6 +2034,9 @@ describe('FSHImporter', () => {
           false,
           false
         );
+        expect(appliedRuleSet.rules[0].sourceInfo.file).toBe('RuleSet.fsh');
+        expect(appliedRuleSet.rules[0].sourceInfo.location.startLine).toBe(5);
+        expect(appliedRuleSet.rules[0].sourceInfo.location.endLine).toBe(5);
         assertAssignmentRule(
           appliedRuleSet.rules[1],
           'valueString',
@@ -1993,7 +2044,13 @@ describe('FSHImporter', () => {
           false,
           false
         );
+        expect(appliedRuleSet.rules[1].sourceInfo.file).toBe('RuleSet.fsh');
+        expect(appliedRuleSet.rules[1].sourceInfo.location.startLine).toBe(6);
+        expect(appliedRuleSet.rules[1].sourceInfo.location.endLine).toBe(6);
         assertCardRule(appliedRuleSet.rules[2], 'note', 0, '4');
+        expect(appliedRuleSet.rules[2].sourceInfo.file).toBe('RuleSet.fsh');
+        expect(appliedRuleSet.rules[2].sourceInfo.location.startLine).toBe(7);
+        expect(appliedRuleSet.rules[2].sourceInfo.location.endLine).toBe(7);
       });
 
       it('should parse an insert rule with a parameter that contains right parenthesis', () => {
@@ -2014,6 +2071,15 @@ describe('FSHImporter', () => {
         );
         expect(appliedRuleSet).toBeDefined();
         expect(appliedRuleSet.rules).toHaveLength(1);
+        expect(appliedRuleSet.sourceInfo).toEqual({
+          file: 'RuleSet.fsh',
+          location: {
+            startLine: 1,
+            startColumn: 12,
+            endLine: 2,
+            endColumn: 27
+          }
+        });
         assertAssignmentRule(
           appliedRuleSet.rules[0],
           'status',
@@ -2051,6 +2117,15 @@ describe('FSHImporter', () => {
           ])
         );
         expect(appliedRuleSet).toBeDefined();
+        expect(appliedRuleSet.sourceInfo).toEqual({
+          file: 'RuleSet.fsh',
+          location: {
+            startLine: 4,
+            startColumn: 12,
+            endLine: 7,
+            endColumn: 30
+          }
+        });
         expect(appliedRuleSet.rules).toHaveLength(3);
         assertAssignmentRule(
           appliedRuleSet.rules[0],
@@ -2090,6 +2165,15 @@ describe('FSHImporter', () => {
           JSON.stringify(['MultiParamRuleSet', '#final', '"string value"', '7'])
         );
         expect(appliedRuleSet).toBeDefined();
+        expect(appliedRuleSet.sourceInfo).toEqual({
+          file: 'RuleSet.fsh',
+          location: {
+            startLine: 4,
+            startColumn: 12,
+            endLine: 7,
+            endColumn: 30
+          }
+        });
         expect(appliedRuleSet.rules).toHaveLength(3);
         assertAssignmentRule(
           appliedRuleSet.rules[0],
@@ -2135,11 +2219,29 @@ describe('FSHImporter', () => {
           JSON.stringify(['EntryRules', 'Recursive'])
         );
         expect(firstEntryRules).toBeDefined();
+        expect(firstEntryRules.sourceInfo).toEqual({
+          file: 'RuleSet.fsh',
+          location: {
+            startLine: 9,
+            startColumn: 12,
+            endLine: 10,
+            endColumn: 43
+          }
+        });
         expect(firstEntryRules.rules).toHaveLength(1);
         assertInsertRule(firstEntryRules.rules[0], 'RecursiveRules', ['5']);
 
         const recursiveRules = doc.appliedRuleSets.get(JSON.stringify(['RecursiveRules', '5']));
         expect(recursiveRules).toBeDefined();
+        expect(recursiveRules.sourceInfo).toEqual({
+          file: 'RuleSet.fsh',
+          location: {
+            startLine: 12,
+            startColumn: 12,
+            endLine: 14,
+            endColumn: 41
+          }
+        });
         expect(recursiveRules.rules).toHaveLength(2);
         assertCardRule(recursiveRules.rules[0], 'interpretation', 0, '5');
         assertInsertRule(recursiveRules.rules[1], 'EntryRules', ['BaseCase']);
@@ -2147,11 +2249,29 @@ describe('FSHImporter', () => {
         const secondEntryRules = doc.appliedRuleSets.get(
           JSON.stringify(['EntryRules', 'BaseCase'])
         );
+        expect(secondEntryRules.sourceInfo).toEqual({
+          file: 'RuleSet.fsh',
+          location: {
+            startLine: 9,
+            startColumn: 12,
+            endLine: 10,
+            endColumn: 43
+          }
+        });
         expect(secondEntryRules.rules).toHaveLength(1);
         assertInsertRule(secondEntryRules.rules[0], 'BaseCaseRules', ['5']);
 
         const baseCaseRules = doc.appliedRuleSets.get(JSON.stringify(['BaseCaseRules', '5']));
         expect(baseCaseRules).toBeDefined();
+        expect(baseCaseRules.sourceInfo).toEqual({
+          file: 'RuleSet.fsh',
+          location: {
+            startLine: 16,
+            startColumn: 12,
+            endLine: 17,
+            endColumn: 28
+          }
+        });
         expect(baseCaseRules.rules).toHaveLength(1);
         assertCardRule(baseCaseRules.rules[0], 'note', 0, '5');
       });
@@ -2233,6 +2353,15 @@ describe('FSHImporter', () => {
           JSON.stringify(['CardRuleSet', 'nonExistentPath', '7', '4'])
         );
         expect(appliedRuleSet).toBeDefined();
+        expect(appliedRuleSet.sourceInfo).toEqual({
+          file: 'RuleSet.fsh',
+          location: {
+            startLine: 19,
+            startColumn: 12,
+            endLine: 21,
+            endColumn: 30
+          }
+        });
         // This rule is nonsense, of course, but figuring that out is the job of the exporter, not the importer.
         assertCardRule(appliedRuleSet.rules[0], 'nonExistentPath', 7, '4');
         assertCardRule(appliedRuleSet.rules[1], 'note', 7, '4');