Repository: FHIR/sushi
Pull request #700 (2020-12-18 18:22:44)

Soft indexing
Addresses [CIMPL-572](https://standardhealthrecord.atlassian.net/browse/CIMPL-572?atlOrigin=eyJpIjoiZmU0MWJlNzVhMzNkNDkxMWFjOGU2MTRjN2IxOWRlOTQiLCJwIjoiaiJ9), introducing soft indexing as a post-1.0 feature. For example:

This...
```
* rest[serverSlice].operation[0].name = "mcode-patient-bundle"
* rest[serverSlice].operation[0].definition = "http://hl7.org/fhir/us/mcode/OperationDefinition/mcode-patient-everything"
* rest[serverSlice].operation[0].extension.url = "http://hl7.org/fhir/StructureDefinition/capabilitystatement-expectation"
* rest[serverSlice].operation[0].extension.valueCode = #SHALL
```

Can now be rewritten as...

```
* rest[serverSlice].operation[+].name = "mcode-patient-bundle"
* rest[serverSlice].operation[=].definition = "http://hl7.org/fhir/us/mcode/OperationDefinition/mcode-patient-everything"
* rest[serverSlice].operation[=].extension.url = "http://hl7.org/fhir/StructureDefinition/capabilitystatement-expectation"
* rest[serverSlice].operation[=].extension.valueCode = #SHALL
```
In the current implementation, a `common.ts` file has been created within the `export` folder. This new file contains the `resolveSoftIndexing` helper function, which takes an array of `Rule`s as an argument and converts the soft indexing characters within the path to the corresponding index number. The Exporter classes import this `resolveSoftIndexing` helper function, and call it immediately prior to applying rules. Test cases for soft indexing have been added to `InstanceExporter.test.ts`, although more can be added to the other Exporter test files if we feel that's appropriate.  

cmoesel: So... it turns out my hunch was partially correct, but when I tested this again, my second use case above (with Robert Smith) was still not working correctly.  It turns out there were multiple things going on.  In this case, the third `insert` rule (which was exactly the same as the first) was getting _dropped_.  My commit 533bafc fixes this.  Can @julianxcarter or @mint-thompson review that before we merge?
mint-thompson: thank you @cmoesel for fixing my mistake in the importer. hooray for soft indexing!!
julianxcarter: Nice catch, @cmoesel! I can both confirm the issue and also confirm the fix in your commit, thank you for the fix! 

Patch:
diff --git a/src/export/CodeSystemExporter.ts b/src/export/CodeSystemExporter.ts
index d941eff..d83de52 100644
--- a/src/export/CodeSystemExporter.ts
+++ b/src/export/CodeSystemExporter.ts
@@ -4,7 +4,7 @@ import { setPropertyOnInstance, applyInsertRules } from '../fhirtypes/common';
 import { FshCodeSystem } from '../fshtypes';
 import { CaretValueRule, ConceptRule } from '../fshtypes/rules';
 import { logger } from '../utils/FSHLogger';
-import { MasterFisher, Type } from '../utils';
+import { MasterFisher, Type, resolveSoftIndexing } from '../utils';
 import { Package } from '.';
 
 export class CodeSystemExporter {
@@ -39,6 +39,7 @@ export class CodeSystemExporter {
     const csStructureDefinition = StructureDefinition.fromJSON(
       this.fisher.fishForFHIR('CodeSystem', Type.Resource)
     );
+    resolveSoftIndexing(rules);
     for (const rule of rules) {
       try {
         const { assignedValue, pathParts } = csStructureDefinition.validateValueAtPath(
diff --git a/src/export/InstanceExporter.ts b/src/export/InstanceExporter.ts
index 6618111..829aaaf 100644
--- a/src/export/InstanceExporter.ts
+++ b/src/export/InstanceExporter.ts
@@ -1,7 +1,7 @@
 import { FSHTank } from '../import/FSHTank';
 import { StructureDefinition, InstanceDefinition, ElementDefinition, PathPart } from '../fhirtypes';
 import { Instance } from '../fshtypes';
-import { logger, Fishable, Type, Metadata } from '../utils';
+import { logger, Fishable, Type, Metadata, resolveSoftIndexing } from '../utils';
 import {
   setPropertyOnInstance,
   replaceReferences,
@@ -30,6 +30,7 @@ export class InstanceExporter implements Fishable {
   ): InstanceDefinition {
     // The fshInstanceDef.rules list may contain insert rules, which will be expanded to AssignmentRules
     applyInsertRules(fshInstanceDef, this.tank);
+    resolveSoftIndexing(fshInstanceDef.rules);
     let rules = fshInstanceDef.rules.map(r => cloneDeep(r)) as AssignmentRule[];
     // Normalize all rules to not use the optional [0] index
     rules.forEach(r => {
diff --git a/src/export/MappingExporter.ts b/src/export/MappingExporter.ts
index 551608a..38095e5 100644
--- a/src/export/MappingExporter.ts
+++ b/src/export/MappingExporter.ts
@@ -1,6 +1,6 @@
 import { FSHTank } from '../import';
 import { Package } from '.';
-import { logger, Type, MasterFisher } from '../utils';
+import { logger, Type, MasterFisher, resolveSoftIndexing } from '../utils';
 import { Mapping } from '../fshtypes';
 import { StructureDefinition, StructureDefinitionMapping, idRegex } from '../fhirtypes';
 import { InvalidFHIRIdError } from '../errors';
@@ -44,6 +44,7 @@ export class MappingExporter {
   private setMappingRules(structDef: StructureDefinition, fshDefinition: Mapping): void {
     // Before applying mapping rules, applyInsertRules will expand any insert rules into mapping rules
     applyInsertRules(fshDefinition, this.tank);
+    resolveSoftIndexing(fshDefinition.rules);
     for (const rule of fshDefinition.rules as MappingRule[]) {
       const element = structDef.findElementByPath(rule.path, this.fisher);
       if (element) {
diff --git a/src/export/StructureDefinitionExporter.ts b/src/export/StructureDefinitionExporter.ts
index 2a97663..d85f2b5 100644
--- a/src/export/StructureDefinitionExporter.ts
+++ b/src/export/StructureDefinitionExporter.ts
@@ -25,7 +25,7 @@ import {
   CaretValueRule,
   ObeysRule
 } from '../fshtypes/rules';
-import { logger, Type, Fishable, Metadata, MasterFisher } from '../utils';
+import { logger, Type, Fishable, Metadata, MasterFisher, resolveSoftIndexing } from '../utils';
 import {
   replaceReferences,
   splitOnPathPeriods,
@@ -151,6 +151,7 @@ export class StructureDefinitionExporter implements Fishable {
    * @param {Profile | Extension} fshDefinition - The Profile or Extension we are exporting
    */
   private setRules(structDef: StructureDefinition, fshDefinition: Profile | Extension): void {
+    resolveSoftIndexing(fshDefinition.rules);
     for (const rule of fshDefinition.rules) {
       const element = structDef.findElementByPath(rule.path, this);
       if (element) {
diff --git a/src/export/ValueSetExporter.ts b/src/export/ValueSetExporter.ts
index 9c9ebe1..854abe2 100644
--- a/src/export/ValueSetExporter.ts
+++ b/src/export/ValueSetExporter.ts
@@ -9,7 +9,7 @@ import { FshValueSet, FshCode, ValueSetFilterValue } from '../fshtypes';
 import { logger } from '../utils/FSHLogger';
 import { ValueSetComposeError, InvalidUriError } from '../errors';
 import { Package } from '.';
-import { MasterFisher, Type } from '../utils';
+import { MasterFisher, Type, resolveSoftIndexing } from '../utils';
 import {
   CaretValueRule,
   ValueSetComponentRule,
@@ -18,7 +18,6 @@ import {
 } from '../fshtypes/rules';
 import { setPropertyOnInstance, applyInsertRules } from '../fhirtypes/common';
 import { isUri } from 'valid-url';
-
 export class ValueSetExporter {
   constructor(private readonly tank: FSHTank, private pkg: Package, private fisher: MasterFisher) {}
 
@@ -104,6 +103,7 @@ export class ValueSetExporter {
     const vsStructureDefinition = StructureDefinition.fromJSON(
       this.fisher.fishForFHIR('ValueSet', Type.Resource)
     );
+    resolveSoftIndexing(rules);
     for (const rule of rules) {
       try {
         if (rule instanceof CaretValueRule) {
diff --git a/src/fhirtypes/StructureDefinition.ts b/src/fhirtypes/StructureDefinition.ts
index bf5256f..0a080a4 100644
--- a/src/fhirtypes/StructureDefinition.ts
+++ b/src/fhirtypes/StructureDefinition.ts
@@ -17,11 +17,10 @@ import {
   setPropertyOnDefinitionInstance,
   HasName,
   HasId,
-  splitOnPathPeriods,
   isInheritedResource
 } from './common';
 import { Fishable, Type } from '../utils/Fishable';
-import { applyMixins } from '../utils';
+import { applyMixins, parseFSHPath, assembleFSHPath } from '../utils';
 import { InstanceDefinition } from './InstanceDefinition';
 
 /**
@@ -185,7 +184,7 @@ export class StructureDefinition {
     }
 
     // Parse the FSH Path into a form we can work with
-    const parsedPath = this.parseFSHPath(path);
+    const parsedPath = parseFSHPath(path);
 
     let fhirPathString = this.type;
     let matchingElements = this.elements;
@@ -450,7 +449,7 @@ export class StructureDefinition {
     fisher: Fishable,
     inlineResourceTypes: string[] = []
   ): { assignedValue: any; pathParts: PathPart[] } {
-    const pathParts = this.parseFSHPath(path);
+    const pathParts = parseFSHPath(path);
     let currentPath = '';
     let previousPath = '';
     let currentElement: ElementDefinition;
@@ -554,7 +553,7 @@ export class StructureDefinition {
         );
         if (inlineResourceJSON) {
           const inlineResourceStructDef = StructureDefinition.fromJSON(inlineResourceJSON);
-          const inlinePath = this.assembleFSHPath(pathParts.slice(i + 1));
+          const inlinePath = assembleFSHPath(pathParts.slice(i + 1));
           try {
             const validatedInlineResource = inlineResourceStructDef.validateValueAtPath(
               inlinePath,
@@ -598,52 +597,6 @@ export class StructureDefinition {
     return { assignedValue, pathParts };
   }
 
-  /**
-   * Parses a FSH Path into a more easily usable form
-   * @param {string} fshPath - A syntactically valid path in FSH
-   * @returns {PathPart[]} an array of PathParts that is easier to work with
-   */
-  private parseFSHPath(fshPath: string): PathPart[] {
-    const pathParts: PathPart[] = [];
-    const splitPath = splitOnPathPeriods(fshPath);
-    for (const pathPart of splitPath) {
-      const splitPathPart = pathPart.split('[');
-      if (splitPathPart.length === 1 || pathPart.endsWith('[x]')) {
-        // There are no brackets, or the brackets are for a choice, so just push on the name
-        pathParts.push({ base: pathPart });
-      } else {
-        // We have brackets, let's  save the bracket info
-        let fhirPathBase = splitPathPart[0];
-        // Get the bracket elements and slice off the trailing ']'
-        let brackets = splitPathPart.slice(1).map(s => s.slice(0, -1));
-        // Get rid of any remaining [x] elements in the brackets
-        if (brackets[0] === 'x') {
-          fhirPathBase += '[x]';
-          brackets = brackets.slice(1);
-        }
-        pathParts.push({ base: fhirPathBase, brackets: brackets });
-      }
-    }
-    return pathParts;
-  }
-
-  /**
-   * Assembles a PathPart array back to its original form
-   * @param {PathPart[]} pathParts - An array of pathParts
-   * @returns {string} path - The path corresponding to those pathParts
-   */
-  private assembleFSHPath(pathParts: PathPart[]): string {
-    let path = '';
-    pathParts.forEach((pathPart, i) => {
-      path += pathPart.base;
-      pathPart.brackets?.forEach(bracket => (path += `[${bracket}]`));
-      if (i < pathParts.length - 1) {
-        path += '.';
-      }
-    });
-    return path;
-  }
-
   /**
    * Looks for a matching choice element. If the choice has no existing slice, we slice it and
    * add to the existing StructureDefinition. If the choice has an existing slice, we return that.
@@ -798,7 +751,7 @@ export class StructureDefinition {
    * @returns {string} - The name of the reference if it exists, else undefined
    */
   getReferenceName(path: string, element: ElementDefinition): string {
-    const parsedPath = this.parseFSHPath(path);
+    const parsedPath = parseFSHPath(path);
     const pathEnd = parsedPath.slice(-1)[0];
     if (pathEnd.brackets) {
       const refElement = this.findMatchingRef(pathEnd, [element]);
@@ -829,6 +782,8 @@ export type PathPart = {
   base: string;
   brackets?: string[];
   primitive?: boolean;
+  slices?: string[];
+  prefix?: string;
 };
 
 /**
diff --git a/src/fhirtypes/common.ts b/src/fhirtypes/common.ts
index 29fd3f5..16a91a3 100644
--- a/src/fhirtypes/common.ts
+++ b/src/fhirtypes/common.ts
@@ -474,7 +474,7 @@ export function applyInsertRules(
         ruleSetRule.sourceInfo.appliedFile = rule.sourceInfo.file;
         ruleSetRule.sourceInfo.appliedLocation = rule.sourceInfo.location;
         if (isAllowedRule(fshDefinition, ruleSetRule)) {
-          expandedRules.push(ruleSetRule);
+          expandedRules.push(cloneDeep(ruleSetRule));
         } else {
           logger.error(
             `Rule of type ${ruleSetRule.constructor.name} cannot be applied to entity of type ${fshDefinition.constructor.name}`,
diff --git a/src/import/FSHImporter.ts b/src/import/FSHImporter.ts
index 4b7bba8..8457d33 100644
--- a/src/import/FSHImporter.ts
+++ b/src/import/FSHImporter.ts
@@ -1481,7 +1481,6 @@ export class FSHImporter extends FSHVisitor {
                   appliedRuleSet.sourceInfo.location.startLine - 1;
               });
               this.currentDoc.appliedRuleSets.set(ruleSetIdentifier, appliedRuleSet);
-              return insertRule;
             } else {
               logger.error(
                 `Failed to parse RuleSet ${
@@ -1489,6 +1488,7 @@ export class FSHImporter extends FSHVisitor {
                 } with provided parameters (${insertRule.params.join(', ')})`,
                 insertRule.sourceInfo
               );
+              return;
             }
           }
         } else {
@@ -1496,16 +1496,17 @@ export class FSHImporter extends FSHVisitor {
             `Incorrect number of parameters applied to RuleSet ${insertRule.ruleSet}`,
             insertRule.sourceInfo
           );
+          return;
         }
       } else {
         logger.error(
           `Could not find parameterized RuleSet named ${insertRule.ruleSet}`,
           insertRule.sourceInfo
         );
+        return;
       }
-    } else {
-      return insertRule;
     }
+    return insertRule;
   }
 
   private parseRulesetReference(reference: string): [string, string] {
diff --git a/src/utils/PathUtils.ts b/src/utils/PathUtils.ts
new file mode 100644
index 0000000..f70b83c
--- /dev/null
+++ b/src/utils/PathUtils.ts
@@ -0,0 +1,160 @@
+import { PathPart } from '../fhirtypes';
+import { splitOnPathPeriods } from '../fhirtypes/common';
+import { CaretValueRule, Rule } from '../fshtypes/rules';
+import { logger } from './FSHLogger';
+
+/**
+ * Parses a FSH Path into a more easily usable form
+ * @param {string} fshPath - A syntactically valid path in FSH
+ * @returns {PathPart[]} an array of PathParts that is easier to work with
+ */
+export function parseFSHPath(fshPath: string): PathPart[] {
+  const pathParts: PathPart[] = [];
+  const seenSlices: string[] = [];
+  const indexRegex = new RegExp('^[0-9]$');
+  const splitPath = fshPath === '.' ? [fshPath] : splitOnPathPeriods(fshPath);
+  for (const pathPart of splitPath) {
+    const splitPathPart = pathPart.split('[');
+    if (splitPathPart.length === 1 || pathPart.endsWith('[x]')) {
+      // There are no brackets, or the brackets are for a choice, so just push on the name
+      pathParts.push({ base: pathPart });
+    } else {
+      // We have brackets, let's  save the bracket info
+      let fhirPathBase = splitPathPart[0];
+      // Get the bracket elements and slice off the trailing ']'
+      let brackets = splitPathPart.slice(1).map(s => s.slice(0, -1));
+      // Get rid of any remaining [x] elements in the brackets
+      if (brackets[0] === 'x') {
+        fhirPathBase += '[x]';
+        brackets = brackets.slice(1);
+      }
+      brackets.forEach(bracket => {
+        if (!indexRegex.test(bracket) && !(bracket === '+' || bracket === '=')) {
+          seenSlices.push(bracket);
+        }
+      });
+      if (seenSlices.length > 0) {
+        pathParts.push({
+          base: fhirPathBase,
+          brackets: brackets,
+          slices: seenSlices
+        });
+      } else {
+        pathParts.push({ base: fhirPathBase, brackets: brackets });
+      }
+    }
+  }
+  return pathParts;
+}
+
+/**
+ * Assembles a PathPart array back to its original form
+ * @param {PathPart[]} pathParts - An array of pathParts
+ * @returns {string} path - The path corresponding to those pathParts
+ */
+export function assembleFSHPath(pathParts: PathPart[]): string {
+  let path = '';
+  pathParts.forEach((pathPart, i) => {
+    path += pathPart.base;
+    pathPart.brackets?.forEach(bracket => (path += `[${bracket}]`));
+    if (i < pathParts.length - 1) {
+      path += '.';
+    }
+  });
+  return path;
+}
+
+/**
+ *
+ * @param {PathPart} element - A single element in a rules path
+ * @param {Map<string, number} pathMap - A map containing an element's name as the key and that element's updated index as the value
+ */
+function convertSoftIndexes(element: PathPart, pathMap: Map<string, number>) {
+  // Must account for a pathPart's base name, prior portions of the path, as well as any slices it's contained in.
+  const mapName = `${element.prefix ?? ''}.${element.base}|${(element.slices ?? []).join('|')}`;
+  const indexRegex = /^[0-9]+$/;
+  if (!pathMap.has(mapName)) {
+    pathMap.set(mapName, 0);
+    if (element.brackets?.includes('+')) {
+      element.brackets[element.brackets.indexOf('+')] = '0';
+    } else if (element.brackets?.includes('=')) {
+      // If a sequence begins with a '=', we log an error but assume a value of 0
+      element.brackets[element.brackets.indexOf('=')] = '0';
+      throw new Error(
+        'The first index in a Soft Indexing sequence must be "+", an actual index of "0" has been assumed'
+      );
+    }
+  } else {
+    element.brackets?.forEach((bracket: string, index: number) => {
+      if (bracket === '+') {
+        const newIndex = pathMap.get(mapName) + 1;
+        element.brackets[index] = newIndex.toString();
+        pathMap.set(mapName, newIndex);
+      } else if (bracket === '=') {
+        const currentIndex = pathMap.get(mapName);
+        element.brackets[index] = currentIndex.toString();
+      } else if (indexRegex.test(bracket)) {
+        // If a numeric index is found, we update our pathMap so subsequent soft indexes are converted in that context
+        pathMap.set(mapName, parseInt(bracket));
+      }
+    });
+  }
+}
+
+/**
+ * Replaces soft indexs in rule paths with corresponding numbers
+ * @param {Rule[]} rules - An array of Rules
+ */
+export function resolveSoftIndexing(rules: Array<Rule | CaretValueRule>): void {
+  const pathMap: Map<string, number> = new Map();
+  const caretPathMap: Map<string, Map<string, number>> = new Map();
+
+  // Parsing and separating rules by base name and bracket indexes
+  const parsedRules = rules.map(rule => {
+    const parsedPath: { path: PathPart[]; caretPath?: PathPart[] } = {
+      path: parseFSHPath(rule.path)
+    };
+    // If we have a CaretValueRule, we'll need a second round of parsing for the caret path
+    if (rule instanceof CaretValueRule) {
+      parsedPath.caretPath = parseFSHPath(rule.caretPath);
+    }
+    return parsedPath;
+  });
+
+  // Replacing Soft indexes with numbers
+  parsedRules.forEach((parsedRule, ruleIndex) => {
+    const originalRule = rules[ruleIndex];
+    parsedRule.path.forEach((element: PathPart, elementIndex) => {
+      // Add a prefix to the current element containing previously parsed rule elements
+      element.prefix = assembleFSHPath(parsedRule.path.slice(0, elementIndex));
+      try {
+        convertSoftIndexes(element, pathMap);
+      } catch (e) {
+        logger.error(e.message, originalRule.sourceInfo);
+      }
+    });
+    originalRule.path = assembleFSHPath(parsedRule.path); // Assembling the separated rule path back into a normal string
+
+    parsedRule.caretPath?.forEach((element: PathPart, elementIndex) => {
+      // Caret path indexes should only be resolved in the context of a specific path
+      // Each normal path has a separate map to keep track of the caret path indexes
+      if (!caretPathMap.has(originalRule.path)) {
+        caretPathMap.set(originalRule.path, new Map());
+      }
+
+      const elementCaretPathMap = caretPathMap.get(originalRule.path);
+      // Add a prefix to the current element containing previously parsed rule elements
+      element.prefix = assembleFSHPath(parsedRule.caretPath.slice(0, elementIndex));
+      try {
+        convertSoftIndexes(element, elementCaretPathMap);
+      } catch (e) {
+        logger.error(e.message, originalRule.sourceInfo);
+      }
+    });
+
+    // If a rule is a CaretValueRule, we assemble its caretPath as well
+    if (originalRule instanceof CaretValueRule) {
+      originalRule.caretPath = assembleFSHPath(parsedRule.caretPath);
+    }
+  });
+}
diff --git a/src/utils/index.ts b/src/utils/index.ts
index 6672944..8ceca34 100644
--- a/src/utils/index.ts
+++ b/src/utils/index.ts
@@ -4,3 +4,4 @@ export * from './MasterFisher';
 export * from './Mixin';
 export * from './InstanceDefinitionUtils';
 export * from './Processing';
+export * from './PathUtils';


Test patch:
diff --git a/test/export/CodeSystemExporter.test.ts b/test/export/CodeSystemExporter.test.ts
index 99bb6ba..5b20089 100644
--- a/test/export/CodeSystemExporter.test.ts
+++ b/test/export/CodeSystemExporter.test.ts
@@ -236,6 +236,37 @@ describe('CodeSystemExporter', () => {
     });
   });
 
+  it('should resolve soft indexing when applying Caret Value rules', () => {
+    const codeSystem = new FshCodeSystem('CaretCodeSystem');
+    const contactRule1 = new CaretValueRule('');
+    contactRule1.caretPath = 'contact[+].name';
+    contactRule1.value = 'Example Name';
+    codeSystem.rules.push(contactRule1);
+
+    const contactRule2 = new CaretValueRule('');
+    contactRule2.caretPath = 'contact[=].telecom[+].rank';
+    contactRule2.value = 1;
+    codeSystem.rules.push(contactRule2);
+
+    const contactRule3 = new CaretValueRule('');
+    contactRule3.caretPath = 'contact[=].telecom[=].value';
+    contactRule3.value = 'example@email.com';
+    codeSystem.rules.push(contactRule3);
+
+    const exported = exporter.exportCodeSystem(codeSystem);
+    expect(exported.contact).toEqual([
+      {
+        name: 'Example Name',
+        telecom: [
+          {
+            rank: 1,
+            value: 'example@email.com'
+          }
+        ]
+      }
+    ]);
+  });
+
   it('should not override count when ^count is provided by user', () => {
     const codeSystem = new FshCodeSystem('MyCodeSystem');
     const rule = new CaretValueRule('');
@@ -366,6 +397,47 @@ describe('CodeSystemExporter', () => {
       expect(exported.title).toBe('Wow fancy');
     });
 
+    it('should resolve soft indexing when inserting an insert rule', () => {
+      // RuleSet: Bar
+      // * ^contact[+].name = Example Name
+      // * ^concept[=].telecom[+].rank = 1
+      // * ^concept[=].telecom[=].value = example@email.com
+      //
+      // CodeSystem: Foo
+      // * insert Bar
+      const contactRule1 = new CaretValueRule('');
+      contactRule1.caretPath = 'contact[+].name';
+      contactRule1.value = 'Example Name';
+      ruleSet.rules.push(contactRule1);
+
+      const contactRule2 = new CaretValueRule('');
+      contactRule2.caretPath = 'contact[=].telecom[+].rank';
+      contactRule2.value = 1;
+      ruleSet.rules.push(contactRule2);
+
+      const contactRule3 = new CaretValueRule('');
+      contactRule3.caretPath = 'contact[=].telecom[=].value';
+      contactRule3.value = 'example@email.com';
+      ruleSet.rules.push(contactRule3);
+
+      const insertRule = new InsertRule();
+      insertRule.ruleSet = 'Bar';
+      cs.rules.push(insertRule);
+
+      const exported = exporter.exportCodeSystem(cs);
+      expect(exported.contact).toEqual([
+        {
+          name: 'Example Name',
+          telecom: [
+            {
+              rank: 1,
+              value: 'example@email.com'
+            }
+          ]
+        }
+      ]);
+    });
+
     it('should update count when applying concepts from an insert rule', () => {
       // RuleSet: Bar
       // * #lion
diff --git a/test/export/InstanceExporter.test.ts b/test/export/InstanceExporter.test.ts
index 1bf909f..80d03cd 100644
--- a/test/export/InstanceExporter.test.ts
+++ b/test/export/InstanceExporter.test.ts
@@ -916,6 +916,32 @@ describe('InstanceExporter', () => {
       });
     });
 
+    it('should assign elements with soft indexing used within a path', () => {
+      const assignedValRule = new AssignmentRule('name[+].given');
+      assignedValRule.value = 'John';
+      patientInstance.rules.push(assignedValRule);
+      const assignedValRule2 = new AssignmentRule('name[=].family');
+      assignedValRule2.value = 'Johnson';
+      patientInstance.rules.push(assignedValRule2);
+      const assignedValRule3 = new AssignmentRule('name[+].given');
+      assignedValRule3.value = 'Johnny';
+      patientInstance.rules.push(assignedValRule3);
+      const assignedValRule4 = new AssignmentRule('name[=].family');
+      assignedValRule4.value = 'Jackson';
+      patientInstance.rules.push(assignedValRule4);
+      const exported = exportInstance(patientInstance);
+      expect(exported.name).toEqual([
+        {
+          given: ['John'],
+          family: 'Johnson'
+        },
+        {
+          given: ['Johnny'],
+          family: 'Jackson'
+        }
+      ]);
+    });
+
     it('should assign cardinality 1..n elements that are assigned by array pattern[x] from a parent on the SD', () => {
       const assignedValRule = new AssignmentRule('maritalStatus');
       assignedValRule.value = new FshCode('foo', 'http://foo.com');
@@ -2545,6 +2571,7 @@ describe('InstanceExporter', () => {
 
   describe('#insertRules', () => {
     let instance: Instance;
+    let patientInstance: Instance;
     let ruleSet: RuleSet;
 
     beforeEach(() => {
@@ -2552,6 +2579,10 @@ describe('InstanceExporter', () => {
       instance.instanceOf = 'Resource';
       doc.instances.set(instance.name, instance);
 
+      patientInstance = new Instance('TestPatient');
+      patientInstance.instanceOf = 'Patient';
+      doc.instances.set(patientInstance.name, patientInstance);
+
       ruleSet = new RuleSet('Bar');
       doc.ruleSets.set(ruleSet.name, ruleSet);
     });
@@ -2575,6 +2606,36 @@ describe('InstanceExporter', () => {
       expect(exported.id).toBe('my-id');
     });
 
+    it('should assign elements from a rule set with soft indexing used within a path', () => {
+      const assignedValRule = new AssignmentRule('name[+].given');
+      assignedValRule.value = 'John';
+      ruleSet.rules.push(assignedValRule);
+      const assignedValRule2 = new AssignmentRule('name[=].family');
+      assignedValRule2.value = 'Johnson';
+      ruleSet.rules.push(assignedValRule2);
+      const assignedValRule3 = new AssignmentRule('name[+].given');
+      assignedValRule3.value = 'Johnny';
+      ruleSet.rules.push(assignedValRule3);
+      const assignedValRule4 = new AssignmentRule('name[=].family');
+      assignedValRule4.value = 'Jackson';
+      ruleSet.rules.push(assignedValRule4);
+
+      const insertRule = new InsertRule();
+      insertRule.ruleSet = 'Bar';
+      patientInstance.rules.push(insertRule);
+      const exported = exporter.exportInstance(patientInstance);
+      expect(exported.name).toEqual([
+        {
+          given: ['John'],
+          family: 'Johnson'
+        },
+        {
+          given: ['Johnny'],
+          family: 'Jackson'
+        }
+      ]);
+    });
+
     it('should log an error and not apply rules from an invalid insert rule', () => {
       // RuleSet: Bar
       // * ^title = "Wow fancy"
diff --git a/test/export/StructureDefinition.ProfileExporter.test.ts b/test/export/StructureDefinition.ProfileExporter.test.ts
index 8ee047e..3ef0349 100644
--- a/test/export/StructureDefinition.ProfileExporter.test.ts
+++ b/test/export/StructureDefinition.ProfileExporter.test.ts
@@ -155,7 +155,7 @@ describe('ProfileExporter', () => {
     expect(exported[0].contained).toBeUndefined();
     expect(exporter.deferredRules.size).toBe(1);
     expect(exporter.deferredRules.get(exported[0]).length).toBe(1);
-    expect(exporter.deferredRules.get(exported[0])).toContain(caretValueRule);
+    expect(exporter.deferredRules.get(exported[0])).toContainEqual(caretValueRule);
   });
 
   it('should log an error when an inline extension is used', () => {
diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts
index 890711d..0f390fe 100644
--- a/test/export/StructureDefinitionExporter.test.ts
+++ b/test/export/StructureDefinitionExporter.test.ts
@@ -2007,6 +2007,47 @@ describe('StructureDefinitionExporter', () => {
     );
   });
 
+  it('should resolve soft indexing within Caret Paths on profiles', () => {
+    // Profile: TestPatient
+    // Parent: Patient
+    // * address.line ^slicing.discriminator[+].type = #pattern
+    // * address.line ^slicing.discriminator[=].path = "$this"
+    // * address.line ^slicing.rules = #open
+    // * address.line contains ApartmentName 0..1
+
+    const testPatient = new Profile('TestPatient');
+    testPatient.parent = 'Patient';
+
+    const slicingTypeLine = new CaretValueRule('address.line');
+    slicingTypeLine.caretPath = 'slicing.discriminator[+].type';
+    slicingTypeLine.value = new FshCode('pattern');
+    const slicingPathLine = new CaretValueRule('address.line');
+    slicingPathLine.caretPath = 'slicing.discriminator[=].path';
+    slicingPathLine.value = 'code';
+    const slicingRulesLine = new CaretValueRule('address.line');
+    slicingRulesLine.caretPath = 'slicing.rules';
+    slicingRulesLine.value = new FshCode('open');
+    const containsAddressLine = new ContainsRule('address.line');
+    containsAddressLine.items.push({ name: 'ApartmentName' });
+    const apartmentNameCard = new CardRule('address.line[ApartmentName]');
+    apartmentNameCard.min = 0;
+    apartmentNameCard.max = '1';
+
+    testPatient.rules.push(
+      slicingTypeLine,
+      slicingPathLine,
+      slicingRulesLine,
+      containsAddressLine,
+      apartmentNameCard
+    );
+
+    exporter.exportStructDef(testPatient);
+    const sd = pkg.profiles[0];
+    const addressLineElement = sd.findElement('Patient.address.line');
+    expect(addressLineElement.slicing.discriminator[0].type).toEqual('pattern');
+    expect(addressLineElement.slicing.discriminator[0].path).toEqual('code');
+  });
+
   it('should not apply a AssignmentRule to a slice when it would conflict with a child slice of the list element', () => {
     // Instance: CustomPostalAddress
     // InstanceOf: Address
diff --git a/test/export/ValueSetExporter.test.ts b/test/export/ValueSetExporter.test.ts
index 065f768..60c5527 100644
--- a/test/export/ValueSetExporter.test.ts
+++ b/test/export/ValueSetExporter.test.ts
@@ -605,6 +605,44 @@ describe('ValueSetExporter', () => {
     });
   });
 
+  it('should apply a CaretValueRule with soft indexing', () => {
+    const valueSet = new FshValueSet('AppleVS');
+    const caretRule1 = new CaretValueRule('');
+    caretRule1.caretPath = 'contact[+].name';
+    caretRule1.value = 'Johnny Appleseed';
+    valueSet.rules.push(caretRule1);
+    const caretRule2 = new CaretValueRule('');
+    caretRule2.caretPath = 'contact[=].telecom[+].rank';
+    caretRule2.value = 1;
+    valueSet.rules.push(caretRule2);
+    const caretRule3 = new CaretValueRule('');
+    caretRule3.caretPath = 'contact[=].telecom[=].value';
+    caretRule3.value = 'email.email@email.com';
+    valueSet.rules.push(caretRule3);
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export().valueSets;
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      resourceType: 'ValueSet',
+      id: 'AppleVS',
+      name: 'AppleVS',
+      url: 'http://hl7.org/fhir/us/minimal/ValueSet/AppleVS',
+      version: '1.0.0',
+      status: 'active',
+      contact: [
+        {
+          name: 'Johnny Appleseed',
+          telecom: [
+            {
+              rank: 1,
+              value: 'email.email@email.com'
+            }
+          ]
+        }
+      ]
+    });
+  });
+
   it('should log a message when applying invalid CaretValueRule', () => {
     const valueSet = new FshValueSet('DinnerVS');
     const rule = new CaretValueRule('').withFile('InvalidValue.fsh').withLocation([6, 3, 6, 12]);
@@ -702,6 +740,38 @@ describe('ValueSetExporter', () => {
       expect(exported.title).toBe('Wow fancy');
     });
 
+    it('should apply a CaretValueRule from a rule set with soft indexing', () => {
+      const caretRule1 = new CaretValueRule('');
+      caretRule1.caretPath = 'contact[+].name';
+      caretRule1.value = 'Johnny Appleseed';
+      ruleSet.rules.push(caretRule1);
+      const caretRule2 = new CaretValueRule('');
+      caretRule2.caretPath = 'contact[=].telecom[+].rank';
+      caretRule2.value = 1;
+      ruleSet.rules.push(caretRule2);
+      const caretRule3 = new CaretValueRule('');
+      caretRule3.caretPath = 'contact[=].telecom[=].value';
+      caretRule3.value = 'email.email@email.com';
+      ruleSet.rules.push(caretRule3);
+
+      const insertRule = new InsertRule();
+      insertRule.ruleSet = 'Bar';
+      vs.rules.push(insertRule);
+
+      const exported = exporter.exportValueSet(vs);
+      expect(exported.contact).toEqual([
+        {
+          name: 'Johnny Appleseed',
+          telecom: [
+            {
+              rank: 1,
+              value: 'email.email@email.com'
+            }
+          ]
+        }
+      ]);
+    });
+
     it('should log an error and not apply rules from an invalid insert rule', () => {
       // RuleSet: Bar
       // * experimental = true
diff --git a/test/fhirtypes/StructureDefinition.test.ts b/test/fhirtypes/StructureDefinition.test.ts
index 983bcc1..8d63e59 100644
--- a/test/fhirtypes/StructureDefinition.test.ts
+++ b/test/fhirtypes/StructureDefinition.test.ts
@@ -1104,7 +1104,11 @@ describe('StructureDefinition', () => {
       expect(assignedValue).toBe('foo');
       expect(pathParts.length).toBe(3);
       expect(pathParts[0]).toEqual({ base: 'code' });
-      expect(pathParts[1]).toEqual({ base: 'coding', brackets: ['RespRateCode', '0'] }); // 0 in path parts means value will be set in an array
+      expect(pathParts[1]).toEqual({
+        base: 'coding',
+        brackets: ['RespRateCode', '0'],
+        slices: ['RespRateCode']
+      }); // 0 in path parts means value will be set in an array
       expect(pathParts[2]).toEqual({ primitive: true, base: 'id' });
     });
 
@@ -1139,8 +1143,16 @@ describe('StructureDefinition', () => {
       );
       expect(assignedValue).toBe('foo');
       expect(pathParts.length).toBe(3);
-      expect(pathParts[0]).toEqual({ base: 'category', brackets: ['VSCat', '0'] });
-      expect(pathParts[1]).toEqual({ base: 'coding', brackets: ['0'] });
+      expect(pathParts[0]).toEqual({
+        base: 'category',
+        brackets: ['VSCat', '0'],
+        slices: ['VSCat']
+      });
+      expect(pathParts[1]).toEqual({
+        base: 'coding',
+        brackets: ['0'],
+        slices: ['VSCat']
+      });
       expect(pathParts[2]).toEqual({ primitive: true, base: 'version' });
     });
 
@@ -1152,7 +1164,11 @@ describe('StructureDefinition', () => {
       );
       expect(assignedValue).toBe('foo');
       expect(pathParts.length).toBe(2);
-      expect(pathParts[0]).toEqual({ base: 'extension', brackets: ['required', '3'] });
+      expect(pathParts[0]).toEqual({
+        base: 'extension',
+        brackets: ['required', '3'],
+        slices: ['required']
+      });
       expect(pathParts[1]).toEqual({ primitive: true, base: 'value[x]' });
     });
 
@@ -1182,7 +1198,8 @@ describe('StructureDefinition', () => {
       expect(pathParts.length).toBe(2);
       expect(pathParts[0]).toEqual({
         base: 'extension',
-        brackets: ['patient-mothersMaidenName', '0']
+        brackets: ['patient-mothersMaidenName', '0'],
+        slices: ['patient-mothersMaidenName']
       });
       expect(respRate.elements.length).toBe(originalLength + 5);
     });
@@ -1198,7 +1215,8 @@ describe('StructureDefinition', () => {
       expect(pathParts.length).toBe(3);
       expect(pathParts[1]).toEqual({
         base: 'extension',
-        brackets: ['patient-mothersMaidenName', '0']
+        brackets: ['patient-mothersMaidenName', '0'],
+        slices: ['patient-mothersMaidenName']
       });
       expect(respRate.elements.length).toBe(originalLength + 9);
     });
@@ -1546,7 +1564,11 @@ describe('StructureDefinition', () => {
         );
         expect(assignedValue).toBe('Patient');
         expect(pathParts).toEqual([
-          { base: 'contained', brackets: ['DomainsOnly', '0'] },
+          {
+            base: 'contained',
+            brackets: ['DomainsOnly', '0'],
+            slices: ['DomainsOnly']
+          },
           { base: 'resourceType' }
         ]);
       });
@@ -1559,7 +1581,11 @@ describe('StructureDefinition', () => {
         );
         expect(assignedValue).toBe('Patient');
         expect(pathParts).toEqual([
-          { base: 'contained', brackets: ['PatientsOnly', '0'] },
+          {
+            base: 'contained',
+            brackets: ['PatientsOnly', '0'],
+            slices: ['PatientsOnly']
+          },
           { base: 'resourceType' }
         ]);
       });
diff --git a/test/import/FSHImporter.Profile.test.ts b/test/import/FSHImporter.Profile.test.ts
index 5fc5d87..ff78325 100644
--- a/test/import/FSHImporter.Profile.test.ts
+++ b/test/import/FSHImporter.Profile.test.ts
@@ -2201,6 +2201,20 @@ describe('FSHImporter', () => {
         expect(doc.appliedRuleSets.size).toBe(1);
         // expect one call to visitDoc for the Profile, and one for the generated RuleSet
         expect(visitDocSpy).toHaveBeenCalledTimes(2);
+        // ensure the insert rules are still there (once upon a time, a bug caused the repeated rules to be omitted)
+        const profile = doc.profiles.get('ObservationProfile');
+        expect(profile).toBeDefined();
+        expect(profile.rules).toHaveLength(2);
+        assertInsertRule(profile.rules[0], 'MultiParamRuleSet', [
+          '#preliminary',
+          '"something"',
+          '3'
+        ]);
+        assertInsertRule(profile.rules[1], 'MultiParamRuleSet', [
+          '#preliminary',
+          '"something"',
+          '3'
+        ]);
       });
 
       it('should parse an insert rule with parameters that will use the same RuleSet more than once with different parameters each time', () => {
diff --git a/test/utils/PathUtils.test.ts b/test/utils/PathUtils.test.ts
new file mode 100644
index 0000000..b9436b3
--- /dev/null
+++ b/test/utils/PathUtils.test.ts
@@ -0,0 +1,151 @@
+import { CaretValueRule, Rule } from '../../src/fshtypes/rules';
+import { resolveSoftIndexing } from '../../src/utils';
+
+describe('PathUtils', () => {
+  describe('#resolveSoftIndexing', () => {
+    it('should resolve simple (non-nested) soft indexing', () => {
+      const rules = ['name[+]', 'name[=]', 'name[=]', 'name[+]', 'name[=]'].map(r => new Rule(r));
+      resolveSoftIndexing(rules);
+      expect(rules.map(r => r.path)).toEqual([
+        'name[0]',
+        'name[0]',
+        'name[0]',
+        'name[1]',
+        'name[1]'
+      ]);
+    });
+
+    it('should resolve nested soft indexing w/ recursive paths', () => {
+      // These types of paths are possible in instances of Questionnaire
+      const rules = [
+        'item[+].item[+].item[+]',
+        'item[=].item[+].item[+]',
+        'item[=].item[=].item[+]',
+        'item[=].item[=].item[=]'
+      ].map(r => new Rule(r));
+      resolveSoftIndexing(rules);
+      expect(rules.map(r => r.path)).toEqual([
+        'item[0].item[0].item[0]',
+        'item[0].item[1].item[0]',
+        'item[0].item[1].item[1]',
+        'item[0].item[1].item[1]'
+      ]);
+    });
+
+    it('should resolve soft indexing mixed w/ numeric indexes', () => {
+      // These types of paths are possible in instances of Questionnaire
+      const rules = [
+        'item[+].item[+].item[0]',
+        'item[0].item[+].item[+]',
+        'item[=].item[2].item[+]',
+        'item[=].item[=].item[1]'
+      ].map(r => new Rule(r));
+      resolveSoftIndexing(rules);
+      console.log(JSON.stringify(rules.map(r => r.path)));
+      expect(rules.map(r => r.path)).toEqual([
+        'item[0].item[0].item[0]',
+        'item[0].item[1].item[0]',
+        'item[0].item[2].item[0]',
+        'item[0].item[2].item[1]'
+      ]);
+    });
+
+    it('should resolve soft indexing on caret paths', () => {
+      const rules: CaretValueRule[] = [];
+      const caretRule1 = new CaretValueRule('');
+      caretRule1.caretPath = 'contact[+].name';
+      rules.push(caretRule1);
+      const caretRule2 = new CaretValueRule('');
+      caretRule2.caretPath = 'contact[=].phone';
+      rules.push(caretRule2);
+      const caretRule3 = new CaretValueRule('');
+      caretRule3.caretPath = 'contact[=].email';
+      rules.push(caretRule3);
+      const caretRule4 = new CaretValueRule('');
+      caretRule4.caretPath = 'contact[+].phone';
+      rules.push(caretRule4);
+      resolveSoftIndexing(rules);
+      expect(rules.map(r => r.caretPath)).toEqual([
+        'contact[0].name',
+        'contact[0].phone',
+        'contact[0].email',
+        'contact[1].phone'
+      ]);
+    });
+
+    it('should resolve soft indexing on caret paths and regular paths', () => {
+      const rules: CaretValueRule[] = [];
+      const caretRule1 = new CaretValueRule('item[+]');
+      caretRule1.caretPath = 'contact[+].name';
+      rules.push(caretRule1);
+      const caretRule2 = new CaretValueRule('item[=]');
+      caretRule2.caretPath = 'contact[=].phone';
+      rules.push(caretRule2);
+      const caretRule3 = new CaretValueRule('item[0]');
+      caretRule3.caretPath = 'contact[+].email';
+      rules.push(caretRule3);
+      const caretRule4 = new CaretValueRule('item[+]');
+      caretRule4.caretPath = 'contact[+].phone';
+      rules.push(caretRule4);
+      resolveSoftIndexing(rules);
+      expect(rules.map(r => r.path)).toEqual(['item[0]', 'item[0]', 'item[0]', 'item[1]']);
+      expect(rules.map(r => r.caretPath)).toEqual([
+        'contact[0].name',
+        'contact[0].phone',
+        'contact[1].email',
+        'contact[0].phone'
+      ]);
+    });
+
+    it('should resolve soft indexing on regular paths with slicing', () => {
+      const rules: Rule[] = [];
+      const caretRule1 = new Rule('item[Slice1][+]');
+      rules.push(caretRule1);
+      const caretRule2 = new Rule('item[Slice1][=]');
+      rules.push(caretRule2);
+      const caretRule3 = new Rule('item[Slice2][+]');
+      rules.push(caretRule3);
+      const caretRule4 = new Rule('item[Slice2][+]');
+      rules.push(caretRule4);
+      resolveSoftIndexing(rules);
+      expect(rules.map(r => r.path)).toEqual([
+        'item[Slice1][0]',
+        'item[Slice1][0]',
+        'item[Slice2][0]',
+        'item[Slice2][1]'
+      ]);
+    });
+
+    it('should resolve nested soft indexing w/ similar nested path items', () => {
+      // These types of paths are possible in instances of ValueSet
+      const rules = [
+        'compose.include[+].concept[+]',
+        'compose.include[=].concept[+]',
+        'compose.exclude[+].concept[+]',
+        'compose.exclude[=].concept[+]'
+      ].map(r => new Rule(r));
+      resolveSoftIndexing(rules);
+      expect(rules.map(r => r.path)).toEqual([
+        'compose.include[0].concept[0]',
+        'compose.include[0].concept[1]',
+        'compose.exclude[0].concept[0]',
+        'compose.exclude[0].concept[1]'
+      ]);
+    });
+
+    it('should resolve improper soft indexing, but throw an error', () => {
+      const rules = ['name[=]', 'name[=]', 'name[=]', 'name[+]', 'name[=]'].map(r => new Rule(r));
+      expect(() => {
+        resolveSoftIndexing(rules);
+      }).toThrowError;
+      resolveSoftIndexing(rules);
+      expect(rules.map(r => r.path)).toEqual([
+        'name[0]',
+        'name[0]',
+        'name[0]',
+        'name[1]',
+        'name[1]'
+      ]);
+    });
+  });
+});