Repository: FHIR/sushi (ts)

Issue:
Change extension card to 0..0 in simple extensions; value[x] to 0..0 in complex extensions
It seems FSHers have found it necessary to explicitly zero out the unused elements in extension (depending on whether it is complex or simple). Seems not to complete the publishing process unless they do this. But SUSHI should know which is which and zero out the appropriate element.

Pull request description:
Infer simple or complex extensions
This PR adds support for inferring if a user is creating a simple or complex extension. It fixes #183. This is accomplished by preprocessing Extensions.

If rules are only set on `value[x]` or any of it's elements and the user doesn't constrain the cardinality of `extension`, we add a new rule to constrain `extension` to 0..0, If rules are set only on `extension`, we add a new rule to constrain `value[x]`. If a user applies rules to both properties, we will log an error and not try to constrain either property and will allow the rules to be set.


Patch:
diff --git a/src/export/StructureDefinitionExporter.ts b/src/export/StructureDefinitionExporter.ts
index 0d31069..5405998 100644
--- a/src/export/StructureDefinitionExporter.ts
+++ b/src/export/StructureDefinitionExporter.ts
@@ -167,6 +167,52 @@ export class StructureDefinitionExporter implements Fishable {
     }
   }
 
+  /**
+   * Does any necessary preprocessing of extensions.
+   * @param {Extension} fshDefinition - The extension to do preprocessing on. It is updated directly based on processing.
+   */
+  private preprocessExtension(fshDefinition: Extension): void {
+    let hasRuleAppliedToValueX = false;
+    let hasRuleAppliedToExtension = false;
+    fshDefinition.rules.forEach(rule => {
+      if (rule.path.startsWith('extension')) {
+        if (hasRuleAppliedToValueX) {
+          logger.error(
+            `Extension ${fshDefinition.name} cannot have both a value and sub-extensions`,
+            rule.sourceInfo
+          );
+        }
+        if (!(rule instanceof CardRule && rule.max === '0')) {
+          hasRuleAppliedToExtension = true;
+        }
+      } else if (rule.path.startsWith('value')) {
+        if (hasRuleAppliedToExtension) {
+          logger.error(
+            `Extension ${fshDefinition.name} cannot have both a value and sub-extensions`,
+            rule.sourceInfo
+          );
+        }
+        if (!(rule instanceof CardRule && rule.max === '0')) {
+          hasRuleAppliedToValueX = true;
+        }
+      }
+    });
+
+    // If only value[x] or extension is use, constrain cardinality of the other to 0..0.
+    // If both are used, an error has been logged, but the rules will still be applied.
+    if (hasRuleAppliedToExtension && !hasRuleAppliedToValueX) {
+      const valueCardRule = new CardRule('value[x]');
+      valueCardRule.min = 0;
+      valueCardRule.max = '0';
+      fshDefinition.rules.push(valueCardRule);
+    } else if (hasRuleAppliedToValueX && !hasRuleAppliedToExtension) {
+      const extensionCardRule = new CardRule('extension');
+      extensionCardRule.min = 0;
+      extensionCardRule.max = '0';
+      fshDefinition.rules.push(extensionCardRule);
+    }
+  }
+
   fishForFHIR(item: string, ...types: Type[]) {
     let result = this.fisher.fishForFHIR(item, ...types);
     if (
@@ -230,6 +276,7 @@ export class StructureDefinitionExporter implements Fishable {
     // incomplete definitions to be used to resolve circular reference issues.
     if (structDef.type === 'Extension') {
       this.pkg.extensions.push(structDef);
+      this.preprocessExtension(fshDefinition as Extension);
     } else {
       this.pkg.profiles.push(structDef);
     }


Test patch:
diff --git a/test/export/StructureDefinitionExporter.test.ts b/test/export/StructureDefinitionExporter.test.ts
index 57b9d6b..423f6b8 100644
--- a/test/export/StructureDefinitionExporter.test.ts
+++ b/test/export/StructureDefinitionExporter.test.ts
@@ -1388,6 +1388,130 @@ describe('StructureDefinitionExporter', () => {
     expect(loggerSpy.getLastMessage()).toMatch(/File: InvalidValue\.fsh.*Line: 6\D/s);
   });
 
+  // Extension preprocessing
+  it('should zero out Extension.value[x] when Extension.extension is used', () => {
+    const extension = new Extension('MyInferredComplexExtension');
+    extension.id = 'complex-extension';
+
+    const cardRuleForExtension = new CardRule('extension');
+    cardRuleForExtension.min = 1;
+    cardRuleForExtension.max = '*';
+    extension.rules.push(cardRuleForExtension);
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const valueElement = sd.findElement('Extension.value[x]');
+
+    expect(valueElement.min).toEqual(0);
+    expect(valueElement.max).toEqual('0');
+  });
+
+  it('should not zero out Extension.value[x] if Extension.extension is zeroed out', () => {
+    const extension = new Extension('MyExplicitSimpleExtension');
+    extension.id = 'simple-extension';
+
+    const cardRuleForExtension = new CardRule('extension');
+    cardRuleForExtension.min = 0;
+    cardRuleForExtension.max = '0';
+    extension.rules.push(cardRuleForExtension);
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const valueElement = sd.findElement('Extension.value[x]');
+
+    expect(valueElement.min).toEqual(0);
+    expect(valueElement.max).toEqual('1');
+  });
+
+  it('should log an error if Extension.extension and Extension.value[x] are both used but apply both rules', () => {
+    const extension = new Extension('MyInvalidExtension');
+    extension.id = 'my-invalid-extension';
+
+    const onlyRuleForValueX = new OnlyRule('value[x]');
+    onlyRuleForValueX.types = [{ type: 'string' }];
+    extension.rules.push(onlyRuleForValueX);
+    const containsRuleForExtension = new ContainsRule('extension')
+      .withFile('InvalidExtension.fsh')
+      .withLocation([4, 7, 4, 15]);
+    containsRuleForExtension.items = ['MySlice'];
+    extension.rules.push(containsRuleForExtension);
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const valueElement = sd.findElement('Extension.value[x]');
+    const extensionElement = sd.findElement('Extension.extension:MySlice');
+
+    // Both rules are applied but an error is logged
+    expect(valueElement.type).toEqual([new ElementDefinitionType('string')]);
+    expect(extensionElement).toBeDefined();
+    expect(extensionElement.sliceName).toEqual('MySlice');
+    expect(loggerSpy.getLastMessage()).toMatch(
+      /Extension MyInvalidExtension cannot have both a value and sub-extensions/s
+    );
+    expect(loggerSpy.getLastMessage()).toMatch(/File: InvalidExtension\.fsh.*Line: 4\D/s);
+  });
+
+  it('should zero out Extension.extension when Extension.value[x] is used', () => {
+    const extension = new Extension('MyInferredSimpleExtension');
+    extension.id = 'simple-extension';
+
+    const onlyRuleForValue = new OnlyRule('value[x]');
+    onlyRuleForValue.types = [{ type: 'string' }];
+    extension.rules.push(onlyRuleForValue);
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const extensionElement = sd.findElement('Extension.extension');
+
+    expect(extensionElement.min).toEqual(0);
+    expect(extensionElement.max).toEqual('0');
+  });
+
+  it('should not zero out Extension.extension if Extension.value[x] is zeroed out', () => {
+    const extension = new Extension('MyExplicitComplexExtension');
+    extension.id = 'complex-extension';
+
+    const cardRuleForValue = new CardRule('value[x]');
+    cardRuleForValue.min = 0;
+    cardRuleForValue.max = '0';
+    extension.rules.push(cardRuleForValue);
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const extensionElement = sd.findElement('Extension.extension');
+
+    expect(extensionElement.min).toEqual(0);
+    expect(extensionElement.max).toEqual('*');
+  });
+
+  it('should log an error if Extension.value[x] is changed after Extension.extension is used', () => {
+    const extension = new Extension('MyOtherInvalidExtension');
+    extension.id = 'my-invalid-extension';
+
+    const containsRuleForExtension = new ContainsRule('extension');
+    containsRuleForExtension.items = ['MySlice'];
+    extension.rules.push(containsRuleForExtension);
+    const onlyRuleForValueX = new OnlyRule('value[x]')
+      .withFile('OtherInvalidExtension.fsh')
+      .withLocation([4, 7, 4, 15]);
+    onlyRuleForValueX.types = [{ type: 'string' }];
+    extension.rules.push(onlyRuleForValueX);
+
+    exporter.exportStructDef(extension);
+    const sd = pkg.extensions[0];
+    const extensionElement = sd.findElement('Extension.extension:MySlice');
+    const valueElement = sd.findElement('Extension.value[x]');
+
+    // Both rules are applied but an error is logged
+    expect(extensionElement).toBeDefined();
+    expect(extensionElement.sliceName).toEqual('MySlice');
+    expect(valueElement.type).toEqual([new ElementDefinitionType('string')]);
+    expect(loggerSpy.getLastMessage()).toMatch(
+      /Extension MyOtherInvalidExtension cannot have both a value and sub-extensions/s
+    );
+    expect(loggerSpy.getLastMessage()).toMatch(/File: OtherInvalidExtension\.fsh.*Line: 4\D/s);
+  });
+
   // toJSON
   it('should correctly generate a diff containing only changed elements', () => {
     // We already have separate tests for the differentials, so this just ensures that the