Repository: FHIR/sushi
Pull request #77 (2020-01-08 18:27:59)

Export value set
This pull request adds the FHIR value set to the export capabilities. Many of the fields in the ValueSet class are currently unused, but are included so that the class matches the specifications at http://hl7.org/fhir/R4/valueset.html. The cardinality requirement of the `include` property is checked during the export process.

Additionally, there are some changes in the import process: aliased values are now handled in `from` clauses, and the presence of of a system is checked when composing from a list of concepts.

This pull request addresses https://standardhealthrecord.atlassian.net/browse/CIMPL-152.

cmoesel: I realized one other thing.  [ValueSet]() requires `status` (it is `1..1`).  We don't set it, but since `^` isn't supported yet, there is no way for the author to set it either.  So for now, I suggest we default `status` to `active`.
mint-thompson: Thank you for your helpful and powerful review comments. The latest three commits should resolve all of the issues that have been presented thus far.
cmoesel: One higher-level issue is when FSH value sets are defined like this:
```
ValueSet: 				YesNoUnknownVS
Description:			"A value set containing yes, no, and unknown."
* SCT#373066001			"Yes"
* SCT#373067005			"No"
* SCT#261665006			"Unknown"
```

The exporter exports it like this:
```json
"compose": {
    "include": [
      {
        "system": "http://snomed.info/sct",
        "concept": [
          {
            "code": "373066001",
            "display": "Yes"
          }
        ]
      },
      {
        "system": "http://snomed.info/sct",
        "concept": [
          {
            "code": "373067005",
            "display": "No"
          }
        ]
      },
      {
        "system": "http://snomed.info/sct",
        "concept": [
          {
            "code": "261665006",
            "display": "Unknown"
          }
        ]
      }
    ]
  }
```
Since these are all from the same system, this should be:
```
"compose": {
    "include": [
      {
        "system": "http://snomed.info/sct",
        "concept": [
          {
            "code": "373066001",
            "display": "Yes"
          },
          {
            "code": "373067005",
            "display": "No"
          },
          {
            "code": "261665006",
            "display": "Unknown"
          }
        ]
      }
    ]
  }
```
This affects the rendering of the IG, so we do want to make this change.  I suspect we can choose if we want to address it in the exporter (by checking to see if there is already an `includes` entry w/ the system and codes (without filters)) -- or during import by collecting the codes by system and grouping them together in the FshValueSet.  I'd lean toward the latter, but I think either should be fine.
cmoesel: The second high-level issue is that since we are now exporting value sets, the profiles and extensions that bind to these value sets need to specify the value set they bind to by its URL instead of its name.  The FSH statement can (and should) still bind by name -- but the `StructureDefinition` exporter needs to turn it into a URL.

Using one of the mcode definitions as an example...  We have FSH:
```
Profile: CancerStageParent
Parent: Observation
// bunch of other stuff
* method from CancerStagingSystemVS (extensible)
```
And `CancerStagingSystemVS` is also defined in the same tank:
```
ValueSet:   CancerStagingSystemVS
Title: "Cancer Staging System Value Set"
Description:    "System used for staging. Because SNOMED CT does not currently have a code representing AJCC Version 8, specify the exact text 'AJCC Version 8' in the text sub-field of the code structure, and omit the code."
* SCT#444256004 "American Joint Commission on Cancer, Cancer Staging Manual, 6th edition neoplasm staging system (tumor staging)"
* SCT#443830009 "American Joint Commission on Cancer, Cancer Staging Manual, 7th edition neoplasm staging system (tumor staging)"
* SCT#258235000 "International Union Against Cancer (tumor staging)"
```
Currently, the exported `CancerStageParent` profile has this:
```json
      {
        "id": "Observation.method",
        "path": "Observation.method",
        // bunch of stuff
        "binding": {
          "strength": "extensible",
          "valueSet": "CancerStagingSystemVS"
        }
        // more stuff
      }
```
But that `binding` should actually be:
```
        "binding": {
          "strength": "extensible",
          "valueSet": "http://hl7.org/fhir/us/mcode/ValueSet/CancerStagingSystemVS"
        }
```
So... it seems like the StructureDefinition exporter will need to know how to resolve `CancerStagingSystemVS` to its `url` and bind to _that_.
mint-thompson: The latest findings are addressed: value sets are sorted before being added to the IG, value set exports include the `resourceType` field, and compatible concept components get merged together.
mint-thompson: @cmoesel All of your changes look fine to me. Thank you!

Patch:
diff --git a/src/app.ts b/src/app.ts
index 689dbdb..5f326b2 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -87,7 +87,12 @@ async function app() {
     const igExporter = new IGExporter(outPackage, igDataPath);
     igExporter.export(program.out);
   } else {
-    for (const sd of [...outPackage.profiles, ...outPackage.extensions, ...outPackage.instances]) {
+    for (const sd of [
+      ...outPackage.profiles,
+      ...outPackage.extensions,
+      ...outPackage.instances,
+      ...outPackage.valueSets
+    ]) {
       fs.writeFileSync(
         path.join(program.out, sd.getFileName()),
         JSON.stringify(sd.toJSON(), null, 2),
@@ -100,6 +105,7 @@ async function app() {
   Profiles:    ${outPackage.profiles.length}
   Extensions:  ${outPackage.extensions.length}
   Instances:   ${outPackage.instances.length}
+  ValueSets:   ${outPackage.valueSets.length}
   Errors:      ${stats.numError}
   Warnings:    ${stats.numWarn}`);
 }
diff --git a/src/errors/ValueSetComposeError.ts b/src/errors/ValueSetComposeError.ts
new file mode 100644
index 0000000..7a7f5c2
--- /dev/null
+++ b/src/errors/ValueSetComposeError.ts
@@ -0,0 +1,8 @@
+import { Annotated } from './Annotated';
+
+export class ValueSetComposeError extends Error implements Annotated {
+  specReferences = ['http://hl7.org/fhir/R4/valueset-definitions.html#ValueSet.compose.include'];
+  constructor(valueSetName: string) {
+    super(`ValueSet ${valueSetName} must contain at least one inclusion rule or be empty.`);
+  }
+}
diff --git a/src/errors/ValueSetFilterPropertyError.ts b/src/errors/ValueSetFilterPropertyError.ts
deleted file mode 100644
index 73ed4dd..0000000
--- a/src/errors/ValueSetFilterPropertyError.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export class ValueSetFilterPropertyError extends Error {
-  constructor(public value: string) {
-    super(`Property "${value}" not available in value set filter.`);
-  }
-}
diff --git a/src/errors/index.ts b/src/errors/index.ts
index b3c83f6..377ec0a 100644
--- a/src/errors/index.ts
+++ b/src/errors/index.ts
@@ -13,7 +13,7 @@ export * from './InvalidPeriodError';
 export * from './InvalidRangeValueError';
 export * from './InvalidTypeError';
 export * from './RequiredMetadataError';
-export * from './ValueSetFilterPropertyError';
+export * from './ValueSetComposeError';
 export * from './ValueSetFilterOperatorError';
 export * from './ValueSetFilterMissingValueError';
 export * from './ValueSetFilterValueTypeError';
diff --git a/src/export/FHIRExporter.ts b/src/export/FHIRExporter.ts
index 91b337f..ec945ef 100644
--- a/src/export/FHIRExporter.ts
+++ b/src/export/FHIRExporter.ts
@@ -3,6 +3,7 @@ import { Package } from './Package';
 import { StructureDefinitionExporter } from './StructureDefinitionExporter';
 import { FHIRDefinitions } from '../fhirdefs';
 import { InstanceExporter } from './InstanceExporter';
+import { ValueSetExporter } from './ValueSetExporter';
 /**
  * FHIRExporter handles the processing of FSH documents, storing the FSH types within them as FHIR types.
  * FHIRExporter takes the Profiles and Extensions within the FSHDocuments of a FSHTank and returns them
@@ -12,6 +13,7 @@ export class FHIRExporter {
   private readonly FHIRDefs: FHIRDefinitions;
   private structureDefinitionExporter: StructureDefinitionExporter;
   private instanceExporter: InstanceExporter;
+  private valueSetExporter: ValueSetExporter;
 
   constructor(FHIRDefs: FHIRDefinitions) {
     this.FHIRDefs = FHIRDefs;
@@ -24,10 +26,12 @@ export class FHIRExporter {
       tank,
       this.structureDefinitionExporter.resolve.bind(this.structureDefinitionExporter)
     );
+    this.valueSetExporter = new ValueSetExporter(tank);
 
     const { profileDefs, extensionDefs } = this.structureDefinitionExporter.export();
     const instanceDefs = this.instanceExporter.export();
+    const valueSets = this.valueSetExporter.export();
 
-    return new Package(profileDefs, extensionDefs, instanceDefs, tank.config);
+    return new Package(profileDefs, extensionDefs, instanceDefs, valueSets, tank.config);
   }
 }
diff --git a/src/export/Package.ts b/src/export/Package.ts
index 80dbd37..1ae0a36 100644
--- a/src/export/Package.ts
+++ b/src/export/Package.ts
@@ -1,4 +1,4 @@
-import { StructureDefinition, InstanceDefinition } from '../fhirtypes';
+import { StructureDefinition, InstanceDefinition, ValueSet } from '../fhirtypes';
 import { Config } from '../fshtypes';
 
 export class Package {
@@ -6,6 +6,7 @@ export class Package {
     public readonly profiles: StructureDefinition[],
     public readonly extensions: StructureDefinition[],
     public readonly instances: InstanceDefinition[],
+    public readonly valueSets: ValueSet[],
     public readonly config: Config
   ) {}
 }
diff --git a/src/export/ValueSetExporter.ts b/src/export/ValueSetExporter.ts
new file mode 100644
index 0000000..65f34ed
--- /dev/null
+++ b/src/export/ValueSetExporter.ts
@@ -0,0 +1,108 @@
+import { ValueSet, ValueSetComposeIncludeOrExclude, ValueSetComposeConcept } from '../fhirtypes';
+import { FSHTank } from '../import/FSHTank';
+import {
+  FshValueSet,
+  ValueSetComponent,
+  ValueSetConceptComponent,
+  ValueSetFilterComponent,
+  FshCode,
+  ValueSetFilterValue
+} from '../fshtypes';
+import { logger } from '../utils/FSHLogger';
+import { ValueSetComposeError } from '../errors';
+
+export class ValueSetExporter {
+  public readonly valueSets: ValueSet[] = [];
+  constructor(public readonly tank: FSHTank) {}
+
+  private setMetadata(valueSet: ValueSet, fshDefinition: FshValueSet): void {
+    valueSet.name = fshDefinition.name;
+    valueSet.id = fshDefinition.id;
+    if (fshDefinition.title) {
+      valueSet.title = fshDefinition.title;
+    }
+    if (fshDefinition.description) {
+      valueSet.description = fshDefinition.description;
+    }
+    valueSet.url = `${this.tank.config.canonical}/ValueSet/${valueSet.id}`;
+  }
+
+  private setCompose(valueSet: ValueSet, components: ValueSetComponent[]) {
+    if (components.length > 0) {
+      valueSet.compose = {
+        include: [],
+        exclude: []
+      };
+      components.forEach(component => {
+        const composeElement: ValueSetComposeIncludeOrExclude = {};
+        if (component.from.system) {
+          composeElement.system = component.from.system;
+        }
+        if (component.from.valueSets) {
+          composeElement.valueSet = component.from.valueSets;
+        }
+        if (component instanceof ValueSetConceptComponent && component.concepts.length > 0) {
+          composeElement.concept = component.concepts.map(concept => {
+            const composeConcept: ValueSetComposeConcept = {
+              code: concept.code
+            };
+            if (concept.display) {
+              composeConcept.display = concept.display;
+            }
+            return composeConcept;
+          });
+        } else if (component instanceof ValueSetFilterComponent && component.filters.length > 0) {
+          composeElement.filter = component.filters.map(filter => {
+            return {
+              property: filter.property.toString(),
+              op: filter.operator.toString(),
+              value: this.filterValueToString(filter.value)
+            };
+          });
+        }
+        if (component.inclusion) {
+          valueSet.compose.include.push(composeElement);
+        } else {
+          valueSet.compose.exclude.push(composeElement);
+        }
+      });
+      if (valueSet.compose.exclude.length == 0) {
+        delete valueSet.compose.exclude;
+      }
+    }
+  }
+
+  private filterValueToString(value: ValueSetFilterValue): string {
+    if (value instanceof RegExp) {
+      return value.source;
+    } else if (value instanceof FshCode) {
+      return value.code;
+    } else {
+      return value.toString();
+    }
+  }
+
+  export(): ValueSet[] {
+    for (const valueSet of this.tank.getAllValueSets()) {
+      try {
+        this.exportValueSet(valueSet);
+      } catch (e) {
+        logger.error(e.message, valueSet.sourceInfo);
+      }
+    }
+    return this.valueSets;
+  }
+
+  exportValueSet(fshDefinition: FshValueSet): void {
+    if (this.valueSets.some(vs => vs.name === fshDefinition.name)) {
+      return;
+    }
+    const vs = new ValueSet();
+    this.setMetadata(vs, fshDefinition);
+    this.setCompose(vs, fshDefinition.components);
+    if (vs.compose && vs.compose.include.length == 0) {
+      throw new ValueSetComposeError(fshDefinition.name);
+    }
+    this.valueSets.push(vs);
+  }
+}
diff --git a/src/export/index.ts b/src/export/index.ts
index 9429937..fd95c4f 100644
--- a/src/export/index.ts
+++ b/src/export/index.ts
@@ -1,5 +1,6 @@
 export * from './FHIRExporter';
 export * from './StructureDefinitionExporter';
 export * from './InstanceExporter';
+export * from './ValueSetExporter';
 export * from './exportFHIR';
 export * from './Package';
diff --git a/src/fhirtypes/ValueSet.ts b/src/fhirtypes/ValueSet.ts
new file mode 100644
index 0000000..3d3caa2
--- /dev/null
+++ b/src/fhirtypes/ValueSet.ts
@@ -0,0 +1,91 @@
+import { Meta } from './specialTypes';
+
+import { Extension } from '../fshtypes';
+import { Narrative, Resource, Identifier, CodeableConcept, Coding } from './dataTypes';
+import { ContactDetail, UsageContext } from './metaDataTypes';
+
+/**
+ * Class representing a FHIR R4 ValueSet.
+ *
+ * @see {@link http://hl7.org/fhir/R4/valueset.html | FHIR ValueSet}
+ */
+
+export class ValueSet {
+  id: string;
+  meta: Meta;
+  implicitRules: string;
+  language: string;
+  text: Narrative;
+  contained: Resource[];
+  extension: Extension[];
+  modifierExtension: Extension[];
+  url: string;
+  identifier: Identifier[];
+  version: string;
+  name: string;
+  title: string;
+  status = 'active';
+  experimental: boolean;
+  date: string;
+  publisher: string;
+  contact: ContactDetail[];
+  description: string;
+  useContext: UsageContext[];
+  jurisdiction: CodeableConcept[];
+  immutable: boolean;
+  purpose: string;
+  copyright: string;
+  compose: ValueSetCompose;
+
+  /**
+   * Get the file name for serializing to disk.
+   * @returns {string} the filename
+   */
+  getFileName(): string {
+    return `ValueSet-${this.id}.json`;
+  }
+
+  /**
+   * Exports the ValueSet to a properly formatted FHIR JSON representation.
+   * @returns {any} the FHIR JSON representation of the ValueSet
+   */
+  toJSON(): any {
+    return {
+      resourceType: 'ValueSet',
+      ...this
+    };
+  }
+}
+
+export type ValueSetCompose = {
+  lockedDate?: string;
+  inactive?: boolean;
+  include: ValueSetComposeIncludeOrExclude[];
+  exclude?: ValueSetComposeIncludeOrExclude[];
+};
+
+export type ValueSetComposeIncludeOrExclude = {
+  system?: string;
+  version?: string;
+  valueSet?: string[];
+  concept?: ValueSetComposeConcept[];
+  filter?: ValueSetComposeFilter[];
+};
+
+export type ValueSetComposeConcept = {
+  code: string;
+  display?: string;
+  designation?: ValueSetComposeConceptDesignation[];
+};
+
+export type ValueSetComposeConceptDesignation = {
+  language?: string;
+  use?: Coding;
+  value: string;
+};
+
+export type ValueSetComposeFilter = {
+  property: string;
+  op: string;
+  value: string;
+};
diff --git a/src/fhirtypes/index.ts b/src/fhirtypes/index.ts
index ad09501..47528fa 100644
--- a/src/fhirtypes/index.ts
+++ b/src/fhirtypes/index.ts
@@ -6,3 +6,4 @@ export * from './ElementDefinition';
 export * from './ImplementationGuide';
 export * from './InstanceDefinition';
 export * from './StructureDefinition';
+export * from './ValueSet';
diff --git a/src/fshtypes/ValueSet.ts b/src/fshtypes/FshValueSet.ts
similarity index 90%
rename from src/fshtypes/ValueSet.ts
rename to src/fshtypes/FshValueSet.ts
index 030fc5f..0f136f3 100644
--- a/src/fshtypes/ValueSet.ts
+++ b/src/fshtypes/FshValueSet.ts
@@ -5,7 +5,7 @@ import { ValueSetComponent } from './ValueSetComponent';
  * For more information about the composition of a ValueSet,
  * @see {@link http://hl7.org/fhir/valueset-definitions.html#ValueSet.compose}
  */
-export class ValueSet extends FshEntity {
+export class FshValueSet extends FshEntity {
   id: string;
   title?: string;
   description?: string;
diff --git a/src/fshtypes/ValueSetComponent.ts b/src/fshtypes/ValueSetComponent.ts
index 7fa9b72..6e7460e 100644
--- a/src/fshtypes/ValueSetComponent.ts
+++ b/src/fshtypes/ValueSetComponent.ts
@@ -21,13 +21,6 @@ export type ValueSetComponentFrom = {
   valueSets?: string[];
 };
 
-export enum VsProperty {
-  SYSTEM = 'system',
-  VERSION = 'version',
-  CODE = 'code',
-  DISPLAY = 'display'
-}
-
 export enum VsOperator {
   EQUALS = '=',
   IS_A = 'is-a',
@@ -41,7 +34,9 @@ export enum VsOperator {
 }
 
 export type ValueSetFilter = {
-  property: VsProperty;
+  property: string;
   operator: VsOperator;
-  value: string | RegExp | boolean | FshCode;
+  value: ValueSetFilterValue;
 };
+
+export type ValueSetFilterValue = string | RegExp | boolean | FshCode;
diff --git a/src/fshtypes/index.ts b/src/fshtypes/index.ts
index d1c2922..b361950 100644
--- a/src/fshtypes/index.ts
+++ b/src/fshtypes/index.ts
@@ -9,5 +9,5 @@ export * from './FshRatio';
 export * from './FshReference';
 export * from './Instance';
 export * from './Profile';
-export * from './ValueSet';
+export * from './FshValueSet';
 export * from './ValueSetComponent';
diff --git a/src/ig/IGExporter.ts b/src/ig/IGExporter.ts
index b166ab0..c2e7e17 100644
--- a/src/ig/IGExporter.ts
+++ b/src/ig/IGExporter.ts
@@ -5,6 +5,7 @@ import { ensureDirSync, copySync, outputJSONSync, outputFileSync } from 'fs-extr
 import { Package } from '../export';
 import { ContactDetail, ImplementationGuide } from '../fhirtypes';
 import { logger } from '../utils/FSHLogger';
+import sortBy from 'lodash/sortBy';
 
 /**
  * The IG Exporter exports the FSH artifacts into a file structure supported by the IG Publisher.
@@ -159,9 +160,7 @@ export class IGExporter {
    * @param igPath {string} - the path where the IG is exported to
    */
   private addResources(igPath: string) {
-    const sds = [...this.pkg.profiles, ...this.pkg.extensions].sort((a, b) => {
-      return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
-    });
+    const sds = sortBy([...this.pkg.profiles, ...this.pkg.extensions], sd => sd.name);
     sds.forEach(sd => {
       const sdPath = path.join(igPath, 'input', 'resources', sd.getFileName());
       outputJSONSync(sdPath, sd.toJSON(), { spaces: 2 });
@@ -172,15 +171,26 @@ export class IGExporter {
         exampleBoolean: false
       });
     });
-    this.pkg.instances.forEach(instance => {
-      const instancePath = path.join(igPath, 'input', 'resources', instance.getFileName());
-      outputJSONSync(instancePath, instance.toJSON(), { spaces: 2 });
+    sortBy(this.pkg.instances, instance => instance.id ?? instance.instanceName).forEach(
+      instance => {
+        const instancePath = path.join(igPath, 'input', 'resources', instance.getFileName());
+        outputJSONSync(instancePath, instance.toJSON(), { spaces: 2 });
+        this.ig.definition.resource.push({
+          reference: {
+            reference: `${instance.resourceType}/${instance.id ?? instance.instanceName}`
+          },
+          name: instance.getFileName().slice(0, -5), // Slice off the .json of the file name
+          exampleBoolean: true
+        });
+      }
+    );
+    sortBy(this.pkg.valueSets, valueSet => valueSet.name).forEach(valueSet => {
+      const valueSetPath = path.join(igPath, 'input', 'resources', valueSet.getFileName());
+      outputJSONSync(valueSetPath, valueSet.toJSON(), { spaces: 2 });
       this.ig.definition.resource.push({
-        reference: {
-          reference: `${instance.resourceType}/${instance.id ?? instance.instanceName}`
-        },
-        name: instance.getFileName().slice(0, -5), // Slice off the .json of the file name
-        exampleBoolean: true
+        reference: { reference: `ValueSet/${valueSet.id}` },
+        name: valueSet.title ?? valueSet.name,
+        description: valueSet.description
       });
     });
   }
diff --git a/src/import/FSHDocument.ts b/src/import/FSHDocument.ts
index f660c89..b1b54db 100644
--- a/src/import/FSHDocument.ts
+++ b/src/import/FSHDocument.ts
@@ -1,11 +1,11 @@
-import { Profile, Extension, Instance, ValueSet } from '../fshtypes';
+import { Profile, Extension, Instance, FshValueSet } from '../fshtypes';
 
 export class FSHDocument {
   readonly aliases: Map<string, string>;
   readonly profiles: Map<string, Profile>;
   readonly extensions: Map<string, Extension>;
   readonly instances: Map<string, Instance>;
-  readonly valueSets: Map<string, ValueSet>;
+  readonly valueSets: Map<string, FshValueSet>;
 
   constructor(public readonly file: string) {
     this.aliases = new Map();
diff --git a/src/import/FSHImporter.ts b/src/import/FSHImporter.ts
index 664eee3..41886d7 100644
--- a/src/import/FSHImporter.ts
+++ b/src/import/FSHImporter.ts
@@ -14,14 +14,14 @@ import {
   FshReference,
   TextLocation,
   Instance,
-  ValueSet,
+  FshValueSet,
   ValueSetComponent,
   ValueSetConceptComponent,
-  VsProperty,
   ValueSetFilterComponent,
   ValueSetComponentFrom,
   ValueSetFilter,
-  VsOperator
+  VsOperator,
+  ValueSetFilterValue
 } from '../fshtypes';
 import {
   Rule,
@@ -39,12 +39,13 @@ import { logger } from '../utils/FSHLogger';
 import { TerminalNode } from 'antlr4/tree/Tree';
 import {
   RequiredMetadataError,
-  ValueSetFilterPropertyError,
   ValueSetFilterOperatorError,
   ValueSetFilterValueTypeError,
   ValueSetFilterMissingValueError
 } from '../errors';
 import flatMap from 'lodash/flatMap';
+import isEqual from 'lodash/isEqual';
+import sortBy from 'lodash/sortBy';
 
 enum SdMetadataKey {
   Id = 'Id',
@@ -261,7 +262,7 @@ export class FSHImporter extends FSHVisitor {
   }
 
   visitValueSet(ctx: pc.ValueSetContext) {
-    const valueSet = new ValueSet(ctx.SEQUENCE().getText())
+    const valueSet = new FshValueSet(ctx.SEQUENCE().getText())
       .withLocation(this.extractStartStop(ctx))
       .withFile(this.currentFile);
     this.parseValueSet(valueSet, ctx.vsMetadata(), ctx.vsComponent());
@@ -269,7 +270,7 @@ export class FSHImporter extends FSHVisitor {
   }
 
   private parseValueSet(
-    valueSet: ValueSet,
+    valueSet: FshValueSet,
     metaCtx: pc.VsMetadataContext[] = [],
     componentCtx: pc.VsComponentContext[] = []
   ) {
@@ -301,7 +302,25 @@ export class FSHImporter extends FSHVisitor {
     componentCtx
       .map(vsComponentCtx => this.visitVsComponent(vsComponentCtx))
       .forEach(vsComponent => {
-        valueSet.components.push(vsComponent);
+        // if vsComponent is a concept component,
+        // we may be able to merge its concepts into an existing concept component.
+        if (vsComponent instanceof ValueSetConceptComponent) {
+          const matchedComponent = valueSet.components.find(existingComponent => {
+            return (
+              existingComponent instanceof ValueSetConceptComponent &&
+              vsComponent.inclusion == existingComponent.inclusion &&
+              vsComponent.from.system == existingComponent.from.system &&
+              isEqual(sortBy(vsComponent.from.valueSets), sortBy(existingComponent.from.valueSets))
+            );
+          }) as ValueSetConceptComponent;
+          if (matchedComponent) {
+            matchedComponent.concepts.push(...vsComponent.concepts);
+          } else {
+            valueSet.components.push(vsComponent);
+          }
+        } else {
+          valueSet.components.push(vsComponent);
+        }
       });
   }
 
@@ -732,40 +751,47 @@ export class FSHImporter extends FSHVisitor {
         );
       }
     } else if (ctx.COMMA_DELIMITED_CODES()) {
-      const codes = ctx
-        .COMMA_DELIMITED_CODES()
-        .getText()
-        .split(/\s*,\s+#/);
-      codes[0] = codes[0].slice(1);
-      const location = this.extractStartStop(ctx.COMMA_DELIMITED_CODES());
-      codes.forEach(code => {
-        let codePart: string, description: string;
-        if (code.charAt(0) == '"') {
-          // codePart is a quoted string, just like description (if present).
-          [codePart, description] = code
-            .match(/"([^\s\\"]|\\"|\\\\)+(\s([^\s\\"]|\\"|\\\\)+)*"/g)
-            .map(quotedString => quotedString.slice(1, -1));
-        } else {
-          // codePart is not a quoted string.
-          // if there is a description after the code,
-          // it will be separated by whitespace before the leading "
-          const codeEnd = code.match(/\s+"/)?.index;
-          if (codeEnd) {
-            codePart = code.slice(0, codeEnd);
-            description = code
-              .slice(codeEnd)
-              .trim()
-              .slice(1, -1);
+      if (from.system) {
+        const codes = ctx
+          .COMMA_DELIMITED_CODES()
+          .getText()
+          .split(/\s*,\s+#/);
+        codes[0] = codes[0].slice(1);
+        const location = this.extractStartStop(ctx.COMMA_DELIMITED_CODES());
+        codes.forEach(code => {
+          let codePart: string, description: string;
+          if (code.charAt(0) == '"') {
+            // codePart is a quoted string, just like description (if present).
+            [codePart, description] = code
+              .match(/"([^\s\\"]|\\"|\\\\)+(\s([^\s\\"]|\\"|\\\\)+)*"/g)
+              .map(quotedString => quotedString.slice(1, -1));
           } else {
-            codePart = code.trim();
+            // codePart is not a quoted string.
+            // if there is a description after the code,
+            // it will be separated by whitespace before the leading "
+            const codeEnd = code.match(/\s+"/)?.index;
+            if (codeEnd) {
+              codePart = code.slice(0, codeEnd);
+              description = code
+                .slice(codeEnd)
+                .trim()
+                .slice(1, -1);
+            } else {
+              codePart = code.trim();
+            }
           }
-        }
-        concepts.push(
-          new FshCode(codePart, from.system, description)
-            .withLocation(location)
-            .withFile(this.currentFile)
-        );
-      });
+          concepts.push(
+            new FshCode(codePart, from.system, description)
+              .withLocation(location)
+              .withFile(this.currentFile)
+          );
+        });
+      } else {
+        logger.error('System is required when listing concepts in a value set component', {
+          file: this.currentFile,
+          location: this.extractStartStop(ctx)
+        });
+      }
     }
     return [concepts, from];
   }
@@ -778,19 +804,26 @@ export class FSHImporter extends FSHVisitor {
       ? this.visitVsComponentFrom(ctx.vsComponentFrom())
       : {};
     if (ctx.vsFilterList()) {
-      ctx
-        .vsFilterList()
-        .vsFilterDefinition()
-        .forEach(filterDefinition => {
-          try {
-            filters.push(this.visitVsFilterDefinition(filterDefinition));
-          } catch (e) {
-            logger.error(e, {
-              location: this.extractStartStop(filterDefinition),
-              file: this.currentFile
-            });
-          }
+      if (from.system) {
+        ctx
+          .vsFilterList()
+          .vsFilterDefinition()
+          .forEach(filterDefinition => {
+            try {
+              filters.push(this.visitVsFilterDefinition(filterDefinition));
+            } catch (e) {
+              logger.error(e, {
+                location: this.extractStartStop(filterDefinition),
+                file: this.currentFile
+              });
+            }
+          });
+      } else {
+        logger.error('System is required when filtering a value set component', {
+          file: this.currentFile,
+          location: this.extractStartStop(ctx)
         });
+      }
     }
     return [filters, from];
   }
@@ -798,18 +831,22 @@ export class FSHImporter extends FSHVisitor {
   visitVsComponentFrom(ctx: pc.VsComponentFromContext): ValueSetComponentFrom {
     const from: ValueSetComponentFrom = {};
     if (ctx.vsFromSystem()) {
-      from.system = ctx
-        .vsFromSystem()
-        .SEQUENCE()
-        .getText();
+      from.system = this.aliasAwareValue(
+        ctx
+          .vsFromSystem()
+          .SEQUENCE()
+          .getText()
+      );
     }
     if (ctx.vsFromValueset()) {
       if (ctx.vsFromValueset().SEQUENCE()) {
         from.valueSets = [
-          ctx
-            .vsFromValueset()
-            .SEQUENCE()
-            .getText()
+          this.aliasAwareValue(
+            ctx
+              .vsFromValueset()
+              .SEQUENCE()
+              .getText()
+          )
         ];
       } else if (ctx.vsFromValueset().COMMA_DELIMITED_SEQUENCES()) {
         from.valueSets = ctx
@@ -817,7 +854,7 @@ export class FSHImporter extends FSHVisitor {
           .COMMA_DELIMITED_SEQUENCES()
           .getText()
           .split(',')
-          .map(fromVs => fromVs.trim());
+          .map(fromVs => this.aliasAwareValue(fromVs.trim()));
       }
     }
     return from;
@@ -829,13 +866,7 @@ export class FSHImporter extends FSHVisitor {
    * @see {@link http://hl7.org/fhir/valueset-filter-operator.html}
    */
   visitVsFilterDefinition(ctx: pc.VsFilterDefinitionContext): ValueSetFilter {
-    const property = ctx
-      .SEQUENCE()
-      .getText()
-      .toLocaleLowerCase() as VsProperty;
-    if (Object.values(VsProperty).indexOf(property) < 0) {
-      throw new ValueSetFilterPropertyError(ctx.SEQUENCE().getText());
-    }
+    const property = ctx.SEQUENCE().getText();
     const operator = ctx
       .vsFilterOperator()
       .getText()
@@ -881,7 +912,7 @@ export class FSHImporter extends FSHVisitor {
     };
   }
 
-  visitVsFilterValue(ctx: pc.VsFilterValueContext): string | RegExp | boolean | FshCode {
+  visitVsFilterValue(ctx: pc.VsFilterValueContext): ValueSetFilterValue {
     if (ctx.code()) {
       return this.visitCode(ctx.code());
     } else if (ctx.REGEX()) {
diff --git a/src/import/FSHTank.ts b/src/import/FSHTank.ts
index 00e6e90..4ec1e88 100644
--- a/src/import/FSHTank.ts
+++ b/src/import/FSHTank.ts
@@ -1,5 +1,5 @@
 import { FSHDocument } from './FSHDocument';
-import { Profile, Extension, Instance } from '../fshtypes';
+import { Profile, Extension, Instance, FshValueSet } from '../fshtypes';
 import flatMap from 'lodash/flatMap';
 import { Config } from '../fshtypes/Config';
 
@@ -42,6 +42,14 @@ export class FSHTank {
     return [...this.getAllProfiles(), ...this.getAllExtensions()];
   }
 
+  /**
+   * Gets all value sets in the tank
+   * @returns {FshValueSet[]}
+   */
+  public getAllValueSets(): FshValueSet[] {
+    return flatMap(this.docs, doc => Array.from(doc.valueSets.values()));
+  }
+
   /**
    * Finds the profile in the tank by name, id, or alias, if it exists
    * @param {string} key - The name or id of the profile we're looking for


Test patch:
diff --git a/test/export/FHIRExporter.test.ts b/test/export/FHIRExporter.test.ts
index 26af096..d35e32f 100644
--- a/test/export/FHIRExporter.test.ts
+++ b/test/export/FHIRExporter.test.ts
@@ -11,7 +11,11 @@ describe('FHIRExporter', () => {
     });
     const result = exportFHIR(input, new FHIRDefinitions());
     expect(result).toEqual(
-      new Package([], [], [], { name: 'test', version: '0.0.1', canonical: 'http://example.com' })
+      new Package([], [], [], [], {
+        name: 'test',
+        version: '0.0.1',
+        canonical: 'http://example.com'
+      })
     );
   });
 });
diff --git a/test/export/ValueSetExporter.test.ts b/test/export/ValueSetExporter.test.ts
new file mode 100644
index 0000000..f6e47c0
--- /dev/null
+++ b/test/export/ValueSetExporter.test.ts
@@ -0,0 +1,325 @@
+import { ValueSetExporter } from '../../src/export';
+import { FSHDocument, FSHTank } from '../../src/import';
+import {
+  FshValueSet,
+  ValueSetFilterComponent,
+  ValueSetConceptComponent,
+  FshCode,
+  VsOperator
+} from '../../src/fshtypes';
+import { loggerSpy } from '../testhelpers/loggerSpy';
+
+describe('ValueSetExporter', () => {
+  let doc: FSHDocument;
+  let input: FSHTank;
+  let exporter: ValueSetExporter;
+
+  beforeEach(() => {
+    doc = new FSHDocument('fileName');
+    input = new FSHTank([doc], { name: 'test', version: '0.0.1', canonical: 'http://example.com' });
+    exporter = new ValueSetExporter(input);
+  });
+
+  it('should output empty results with empty input', () => {
+    const exported = exporter.export();
+    expect(exported).toEqual([]);
+  });
+
+  it('should export a single value set', () => {
+    const valueSet = new FshValueSet('BreakfastVS');
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export();
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      name: 'BreakfastVS',
+      id: 'BreakfastVS',
+      status: 'active',
+      url: 'http://example.com/ValueSet/BreakfastVS'
+    });
+  });
+
+  it('should export multiple value sets', () => {
+    const breakfast = new FshValueSet('BreakfastVS');
+    const lunch = new FshValueSet('LunchVS');
+    doc.valueSets.set(breakfast.name, breakfast);
+    doc.valueSets.set(lunch.name, lunch);
+    const exported = exporter.export();
+    expect(exported.length).toBe(2);
+  });
+
+  it('should export a value set with additional metadata', () => {
+    const valueSet = new FshValueSet('BreakfastVS');
+    valueSet.title = 'Breakfast Values';
+    valueSet.description = 'A value set for breakfast items';
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export();
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      name: 'BreakfastVS',
+      id: 'BreakfastVS',
+      status: 'active',
+      title: 'Breakfast Values',
+      description: 'A value set for breakfast items',
+      url: 'http://example.com/ValueSet/BreakfastVS'
+    });
+  });
+
+  it('should export each value set once, even if export is called more than once', () => {
+    const breakfast = new FshValueSet('BreakfastVS');
+    doc.valueSets.set(breakfast.name, breakfast);
+    let exported = exporter.export();
+    expect(exported.length).toBe(1);
+    exported = exporter.export();
+    expect(exported.length).toBe(1);
+  });
+
+  it('should export a value set that includes a component from a system', () => {
+    const valueSet = new FshValueSet('DinnerVS');
+    const component = new ValueSetConceptComponent(true);
+    component.from = { system: 'http://food.org/food' };
+    valueSet.components.push(component);
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export();
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      name: 'DinnerVS',
+      id: 'DinnerVS',
+      status: 'active',
+      url: 'http://example.com/ValueSet/DinnerVS',
+      compose: {
+        include: [{ system: 'http://food.org/food' }]
+      }
+    });
+  });
+
+  it('should export a value set that includes a component from a value set', () => {
+    const valueSet = new FshValueSet('DinnerVS');
+    const component = new ValueSetConceptComponent(true);
+    component.from = {
+      valueSets: [
+        'http://food.org/food/ValueSet/hot-food',
+        'http://food.org/food/ValueSet/cold-food'
+      ]
+    };
+    valueSet.components.push(component);
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export();
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      id: 'DinnerVS',
+      name: 'DinnerVS',
+      url: 'http://example.com/ValueSet/DinnerVS',
+      status: 'active',
+      compose: {
+        include: [
+          {
+            valueSet: [
+              'http://food.org/food/ValueSet/hot-food',
+              'http://food.org/food/ValueSet/cold-food'
+            ]
+          }
+        ]
+      }
+    });
+  });
+
+  it('should export a value set that includes a concept component with at least one concept', () => {
+    const valueSet = new FshValueSet('DinnerVS');
+    const component = new ValueSetConceptComponent(true);
+    component.from = { system: 'http://food.org/food' };
+    component.concepts.push(
+      new FshCode('Pizza', 'http://food.org/food', 'Delicious pizza to share.')
+    );
+    component.concepts.push(
+      new FshCode('Salad', 'http://food.org/food', 'Plenty of fresh vegetables.')
+    );
+    component.concepts.push(new FshCode('Mulch', 'http://food.org/food'));
+    valueSet.components.push(component);
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export();
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      id: 'DinnerVS',
+      name: 'DinnerVS',
+      url: 'http://example.com/ValueSet/DinnerVS',
+      status: 'active',
+      compose: {
+        include: [
+          {
+            system: 'http://food.org/food',
+            concept: [
+              { code: 'Pizza', display: 'Delicious pizza to share.' },
+              { code: 'Salad', display: 'Plenty of fresh vegetables.' },
+              { code: 'Mulch' }
+            ]
+          }
+        ]
+      }
+    });
+  });
+
+  it('should export a value set that includes a filter component with a regex filter', () => {
+    const valueSet = new FshValueSet('BreakfastVS');
+    const component = new ValueSetFilterComponent(true);
+    component.from = { system: 'http://food.org/food' };
+    component.filters.push({
+      property: 'display',
+      operator: VsOperator.REGEX,
+      value: /pancakes|flapjacks/
+    });
+    valueSet.components.push(component);
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export();
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      id: 'BreakfastVS',
+      name: 'BreakfastVS',
+      url: 'http://example.com/ValueSet/BreakfastVS',
+      status: 'active',
+      compose: {
+        include: [
+          {
+            system: 'http://food.org/food',
+            filter: [
+              {
+                property: 'display',
+                op: 'regex',
+                value: 'pancakes|flapjacks'
+              }
+            ]
+          }
+        ]
+      }
+    });
+  });
+
+  it('should export a value set that includes a filter component with a code filter', () => {
+    const valueSet = new FshValueSet('BreakfastVS');
+    const component = new ValueSetFilterComponent(true);
+    component.from = { system: 'http://food.org/food' };
+    component.filters.push({
+      property: 'concept',
+      operator: VsOperator.DESCENDENT_OF,
+      value: new FshCode('Potatoes', 'http://food.org/food')
+    });
+    valueSet.components.push(component);
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export();
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      id: 'BreakfastVS',
+      name: 'BreakfastVS',
+      url: 'http://example.com/ValueSet/BreakfastVS',
+      status: 'active',
+      compose: {
+        include: [
+          {
+            system: 'http://food.org/food',
+            filter: [
+              {
+                property: 'concept',
+                op: 'descendent-of',
+                value: 'Potatoes'
+              }
+            ]
+          }
+        ]
+      }
+    });
+  });
+
+  it('should export a value set that includes a filter component with a string filter', () => {
+    const valueSet = new FshValueSet('BreakfastVS');
+    const component = new ValueSetFilterComponent(true);
+    component.from = { system: 'http://food.org/food' };
+    component.filters.push({
+      property: 'version',
+      operator: VsOperator.EQUALS,
+      value: '3.0.0'
+    });
+    valueSet.components.push(component);
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export();
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      id: 'BreakfastVS',
+      name: 'BreakfastVS',
+      url: 'http://example.com/ValueSet/BreakfastVS',
+      status: 'active',
+      compose: {
+        include: [
+          {
+            system: 'http://food.org/food',
+            filter: [
+              {
+                property: 'version',
+                op: '=',
+                value: '3.0.0'
+              }
+            ]
+          }
+        ]
+      }
+    });
+  });
+
+  it('should export a value set that excludes a component', () => {
+    const valueSet = new FshValueSet('DinnerVS');
+    const includedComponent = new ValueSetFilterComponent(true);
+    includedComponent.from = {
+      system: 'http://food.org/food',
+      valueSets: ['http://food.org/food/ValueSet/baked', 'http://food.org/food/ValueSet/grilled']
+    };
+    const excludedComponent = new ValueSetConceptComponent(false);
+    excludedComponent.from = { system: 'http://food.org/food' };
+    excludedComponent.concepts.push(
+      new FshCode('Cake', 'http://food.org/food', 'A delicious treat for special occasions.')
+    );
+    valueSet.components.push(includedComponent);
+    valueSet.components.push(excludedComponent);
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export();
+    expect(exported.length).toBe(1);
+    expect(exported[0]).toEqual({
+      id: 'DinnerVS',
+      name: 'DinnerVS',
+      url: 'http://example.com/ValueSet/DinnerVS',
+      status: 'active',
+      compose: {
+        include: [
+          {
+            system: 'http://food.org/food',
+            valueSet: [
+              'http://food.org/food/ValueSet/baked',
+              'http://food.org/food/ValueSet/grilled'
+            ]
+          }
+        ],
+        exclude: [
+          {
+            system: 'http://food.org/food',
+            concept: [
+              {
+                code: 'Cake',
+                display: 'A delicious treat for special occasions.'
+              }
+            ]
+          }
+        ]
+      }
+    });
+  });
+
+  it('should log a message when a value set has a logical definition without inclusions', () => {
+    const valueSet = new FshValueSet('BreakfastVS')
+      .withFile('Breakfast.fsh')
+      .withLocation([2, 8, 4, 25]);
+    const candyFilter = new ValueSetFilterComponent(false);
+    candyFilter.from = { valueSets: ['CandyVS'] };
+    valueSet.components.push(candyFilter);
+    doc.valueSets.set(valueSet.name, valueSet);
+    const exported = exporter.export();
+    expect(exported.length).toBe(0);
+    expect(loggerSpy.getLastMessage()).toMatch(/File: Breakfast\.fsh.*Line: 2 - 4\D/s);
+  });
+});
diff --git a/test/fshtypes/ValueSet.test.ts b/test/fshtypes/FshValueSet.test.ts
similarity index 77%
rename from test/fshtypes/ValueSet.test.ts
rename to test/fshtypes/FshValueSet.test.ts
index 15d0ebb..434aef0 100644
--- a/test/fshtypes/ValueSet.test.ts
+++ b/test/fshtypes/FshValueSet.test.ts
@@ -1,10 +1,10 @@
 import 'jest-extended';
-import { ValueSet } from '../../src/fshtypes/ValueSet';
+import { FshValueSet } from '../../src/fshtypes/FshValueSet';
 
 describe('ValueSet', () => {
   describe('#constructor', () => {
     it('should set the properties correctly', () => {
-      const vs = new ValueSet('MyValueSet');
+      const vs = new FshValueSet('MyValueSet');
       expect(vs.name).toBe('MyValueSet');
       expect(vs.id).toBe('MyValueSet');
       expect(vs.title).toBeUndefined();
diff --git a/test/ig/IGExporter.test.ts b/test/ig/IGExporter.test.ts
index 2be4956..63057aa 100644
--- a/test/ig/IGExporter.test.ts
+++ b/test/ig/IGExporter.test.ts
@@ -20,7 +20,7 @@ describe('IGExporter', () => {
     beforeAll(() => {
       const fixtures = path.join(__dirname, 'fixtures', 'simple-ig');
       const config: Config = fs.readJSONSync(path.join(fixtures, 'package.json'));
-      pkg = new Package([], [], [], config);
+      pkg = new Package([], [], [], [], config);
       const resources = path.join(fixtures, 'resources');
       const instances = path.join(fixtures, 'instances');
       fs.readdirSync(resources).forEach(f => {
@@ -233,7 +233,7 @@ describe('IGExporter', () => {
     beforeAll(() => {
       const fixtures = path.join(__dirname, 'fixtures', 'customized-ig');
       const config: Config = fs.readJSONSync(path.join(fixtures, 'package.json'));
-      pkg = new Package([], [], [], config);
+      pkg = new Package([], [], [], [], config);
       exporter = new IGExporter(pkg, path.resolve(fixtures, 'ig-data'));
       tempOut = temp.mkdirSync('sushi-test');
       // No need to regenerate the IG on every test -- generate it once and inspect what you
@@ -319,7 +319,7 @@ describe('IGExporter', () => {
     beforeAll(() => {
       const fixtures = path.join(__dirname, 'fixtures', 'invalid-data-ig');
       const config: Config = fs.readJSONSync(path.join(fixtures, 'package.json'));
-      pkg = new Package([], [], [], config);
+      pkg = new Package([], [], [], [], config);
       exporter = new IGExporter(pkg, path.resolve(fixtures, 'ig-data'));
       tempOut = temp.mkdirSync('sushi-test');
       // No need to regenerate the IG on every test -- generate it once and inspect what you
diff --git a/test/import/FSHImporter.ValueSet.test.ts b/test/import/FSHImporter.ValueSet.test.ts
index 9cd9d2a..b8260d3 100644
--- a/test/import/FSHImporter.ValueSet.test.ts
+++ b/test/import/FSHImporter.ValueSet.test.ts
@@ -3,7 +3,7 @@ import {
   assertValueSetFilterComponent
 } from '../testhelpers/asserts';
 import { loggerSpy } from '../testhelpers/loggerSpy';
-import { FshCode, VsProperty, VsOperator } from '../../src/fshtypes';
+import { FshCode, VsOperator } from '../../src/fshtypes';
 import { importSingleText } from '../testhelpers/importSingleText';
 
 describe('FSHImporter', () => {
@@ -124,6 +124,32 @@ describe('FSHImporter', () => {
         expect(valueSet.sourceInfo.file).toBe('Zoo.fsh');
       });
 
+      it('should parse a value set with a concept specified from an aliased system', () => {
+        const input = `
+        Alias: AQ = http://aquarium.org
+
+        ValueSet: ZooVS
+        * #octopus "Octopus" from system AQ
+        `;
+
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.valueSets.size).toBe(1);
+        const valueSet = result.valueSets.get('ZooVS');
+        expect(valueSet.components.length).toBe(1);
+        assertValueSetConceptComponent(valueSet.components[0], 'http://aquarium.org', undefined, [
+          new FshCode('octopus', 'http://aquarium.org', 'Octopus')
+            .withLocation([5, 11, 5, 28])
+            .withFile('Zoo.fsh')
+        ]);
+        expect(valueSet.sourceInfo.location).toEqual({
+          startLine: 4,
+          startColumn: 9,
+          endLine: 5,
+          endColumn: 43
+        });
+        expect(valueSet.sourceInfo.file).toBe('Zoo.fsh');
+      });
+
       it('should parse a value set with a list of concepts', () => {
         const input = `
         ValueSet: ZooVS
@@ -151,7 +177,69 @@ describe('FSHImporter', () => {
         expect(valueSet.sourceInfo.file).toBe('Zoo.fsh');
       });
 
-      it('should log an error when a concept component does not have a system', () => {
+      it('should merge concept components when possible', () => {
+        const input = `
+        ValueSet: ZooVS
+        * ZOO#hippo "Hippopotamus"
+        * #crocodile "Crocodile", #emu "Emu" from system ZOO
+        * ZOO#alligator "Alligator" from valueset ReptileVS
+        * CRYPTID#jackalope "Jackalope"
+        * exclude ZOO#lion "Lion"
+        * exclude #cobra "Cobra" from system ZOO
+        * exclude ZOO#monitor "Monitor lizard" from valueset ReptileVS
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.valueSets.size).toBe(1);
+        const valueSet = result.valueSets.get('ZooVS');
+        expect(valueSet.components.length).toBe(5);
+        assertValueSetConceptComponent(valueSet.components[0], 'ZOO', undefined, [
+          new FshCode('hippo', 'ZOO', 'Hippopotamus')
+            .withLocation([3, 11, 3, 34])
+            .withFile('Zoo.fsh'),
+          new FshCode('crocodile', 'ZOO', 'Crocodile')
+            .withLocation([4, 11, 4, 44])
+            .withFile('Zoo.fsh'),
+          new FshCode('emu', 'ZOO', 'Emu').withLocation([4, 11, 4, 44]).withFile('Zoo.fsh')
+        ]);
+        assertValueSetConceptComponent(
+          valueSet.components[1],
+          'ZOO',
+          ['ReptileVS'],
+          [
+            new FshCode('alligator', 'ZOO', 'Alligator')
+              .withLocation([5, 11, 5, 35])
+              .withFile('Zoo.fsh')
+          ]
+        );
+        assertValueSetConceptComponent(valueSet.components[2], 'CRYPTID', undefined, [
+          new FshCode('jackalope', 'CRYPTID', 'Jackalope')
+            .withLocation([6, 11, 6, 39])
+            .withFile('Zoo.fsh')
+        ]);
+        assertValueSetConceptComponent(
+          valueSet.components[3],
+          'ZOO',
+          undefined,
+          [
+            new FshCode('lion', 'ZOO', 'Lion').withLocation([7, 19, 7, 33]).withFile('Zoo.fsh'),
+            new FshCode('cobra', 'ZOO', 'Cobra').withLocation([8, 19, 8, 32]).withFile('Zoo.fsh')
+          ],
+          false
+        );
+        assertValueSetConceptComponent(
+          valueSet.components[4],
+          'ZOO',
+          ['ReptileVS'],
+          [
+            new FshCode('monitor', 'ZOO', 'Monitor lizard')
+              .withLocation([9, 19, 9, 46])
+              .withFile('Zoo.fsh')
+          ],
+          false
+        );
+      });
+
+      it('should log an error when a concept component with one concept does not have a system', () => {
         const input = `
         ValueSet: ZooVS
         * #hippo
@@ -163,6 +251,21 @@ describe('FSHImporter', () => {
         assertValueSetConceptComponent(valueSet.components[0], undefined, undefined, []);
         expect(loggerSpy.getLastMessage()).toMatch(/File: Zoo\.fsh.*Line: 3\D/s);
       });
+
+      it('should log an error when a concept component with a list of concepts does not have a system', () => {
+        const input = `
+        ValueSet: ZooVS
+        * #hippo "Hippopotamus", #crocodile "Crocodile"
+        `;
+
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.valueSets.size).toBe(1);
+        const valueSet = result.valueSets.get('ZooVS');
+        expect(valueSet.components.length).toBe(1);
+        assertValueSetConceptComponent(valueSet.components[0], undefined, undefined, []);
+        expect(loggerSpy.getLastMessage()).toMatch(/File: Zoo\.fsh.*Line: 3\D/s);
+      });
+
       it('should log an error when a concept component has a system specified more than once', () => {
         const input = `
         ValueSet: ZooVS
@@ -176,6 +279,7 @@ describe('FSHImporter', () => {
         expect(loggerSpy.getLastMessage()).toMatch(/File: Zoo\.fsh.*Line: 3\D/s);
       });
     });
+
     describe('#ValueSetFilterComponent', () => {
       it('should parse a value set that includes all codes from a system', () => {
         const input = `
@@ -222,6 +326,27 @@ describe('FSHImporter', () => {
         expect(valueSet.sourceInfo.file).toBe('Zoo.fsh');
       });
 
+      it('should parse a value set that includes all codes from an aliased value set', () => {
+        const input = `
+        Alias: Z1 = FirstZooVS
+
+        ValueSet: ZooVS
+        * codes from valueset Z1
+        `;
+        const result = importSingleText(input, 'Zoo.fsh');
+        expect(result.valueSets.size).toBe(1);
+        const valueSet = result.valueSets.get('ZooVS');
+        expect(valueSet.components.length).toBe(1);
+        assertValueSetFilterComponent(valueSet.components[0], undefined, ['FirstZooVS'], []);
+        expect(valueSet.sourceInfo.location).toEqual({
+          startLine: 4,
+          startColumn: 9,
+          endLine: 5,
+          endColumn: 32
+        });
+        expect(valueSet.sourceInfo.file).toBe('Zoo.fsh');
+      });
+
       it('should parse a value set that includes all codes from a system and other value sets', () => {
         const input = `
         ValueSet: ZooVS
@@ -257,7 +382,7 @@ describe('FSHImporter', () => {
         expect(valueSet.components.length).toBe(1);
         assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, [
           {
-            property: VsProperty.VERSION,
+            property: 'version',
             operator: VsOperator.EQUALS,
             value: '2.0'
           }
@@ -281,7 +406,7 @@ describe('FSHImporter', () => {
       it('should parse a value set that uses filter operator is-a', () => {
         const input = `
         ValueSet: AllUrsinesVS
-        * codes from system ZOO where code is-a #bear "Bear"
+        * codes from system ZOO where concept is-a #bear "Bear"
         `;
         const result = importSingleText(input, 'Ursines.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -289,10 +414,10 @@ describe('FSHImporter', () => {
         expect(valueSet.components.length).toBe(1);
         assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, [
           {
-            property: VsProperty.CODE,
+            property: 'concept',
             operator: VsOperator.IS_A,
             value: new FshCode('bear', undefined, 'Bear')
-              .withLocation([3, 49, 3, 60])
+              .withLocation([3, 52, 3, 63])
               .withFile('Ursines.fsh')
           }
         ]);
@@ -301,7 +426,7 @@ describe('FSHImporter', () => {
       it('should log an error when the is-a filter has a non-code value', () => {
         const input = `
         ValueSet: AllUrsinesVS
-        * codes from system ZOO where code is-a "Bear"
+        * codes from system ZOO where concept is-a "Bear"
         `;
         const result = importSingleText(input, 'Ursines.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -315,63 +440,53 @@ describe('FSHImporter', () => {
       it('should parse a value set that uses filter operator descendent-of', () => {
         const input = `
         ValueSet: AllFelinesVS
-        * codes from valueset ZooVS where code descendent-of ZOO#cat
+        * codes from system ZOO where concept descendent-of ZOO#cat
         `;
         const result = importSingleText(input, 'Felines.fsh');
         expect(result.valueSets.size).toBe(1);
         const valueSet = result.valueSets.get('AllFelinesVS');
         expect(valueSet.components.length).toBe(1);
-        assertValueSetFilterComponent(
-          valueSet.components[0],
-          undefined,
-          ['ZooVS'],
-          [
-            {
-              property: VsProperty.CODE,
-              operator: VsOperator.DESCENDENT_OF,
-              value: new FshCode('cat', 'ZOO', undefined)
-                .withLocation([3, 62, 3, 68])
-                .withFile('Felines.fsh')
-            }
-          ]
-        );
+        assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, [
+          {
+            property: 'concept',
+            operator: VsOperator.DESCENDENT_OF,
+            value: new FshCode('cat', 'ZOO', undefined)
+              .withLocation([3, 61, 3, 67])
+              .withFile('Felines.fsh')
+          }
+        ]);
       });
 
       it('should parse a value set that uses filter operator descendant-of, which is the same as descendent-of, but spelled correctly', () => {
         const input = `
         ValueSet: AllFelinesVS
-        * codes from valueset ZooVS where code descendant-of ZOO#cat
+        * codes from system ZOO where concept descendant-of ZOO#cat
         `;
         const result = importSingleText(input, 'Felines.fsh');
         expect(result.valueSets.size).toBe(1);
         const valueSet = result.valueSets.get('AllFelinesVS');
         expect(valueSet.components.length).toBe(1);
-        assertValueSetFilterComponent(
-          valueSet.components[0],
-          undefined,
-          ['ZooVS'],
-          [
-            {
-              property: VsProperty.CODE,
-              operator: VsOperator.DESCENDENT_OF,
-              value: new FshCode('cat', 'ZOO', undefined)
-                .withLocation([3, 62, 3, 68])
-                .withFile('Felines.fsh')
-            }
-          ]
-        );
+        assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, [
+          {
+            property: 'concept',
+            operator: VsOperator.DESCENDENT_OF,
+            value: new FshCode('cat', 'ZOO', undefined)
+              .withLocation([3, 61, 3, 67])
+              .withFile('Felines.fsh')
+          }
+        ]);
       });
 
       it('should log an error when the descendent-of filter has a non-code value', () => {
         const input = `
         ValueSet: AllFelinesVS
-        * codes from valueset ZooVS where code descendent-of "Cat"
+        * codes from system ZOO where concept descendent-of "Cat"
         `;
         const result = importSingleText(input, 'Felines.fsh');
         expect(result.valueSets.size).toBe(1);
         const valueSet = result.valueSets.get('AllFelinesVS');
         expect(valueSet.components.length).toBe(1);
-        assertValueSetFilterComponent(valueSet.components[0], undefined, ['ZooVS'], []);
+        assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, []);
         expect(loggerSpy.getLastMessage()).toMatch(/"descendent-of".*code/);
         expect(loggerSpy.getLastMessage()).toMatch(/File: Felines\.fsh.*Line: 3\D/s);
       });
@@ -379,7 +494,7 @@ describe('FSHImporter', () => {
       it('should parse a value set that uses filter operator is-not-a', () => {
         const input = `
         ValueSet: NonCanineVS
-        * codes from system ZOO where code is-not-a #dog
+        * codes from system ZOO where concept is-not-a #dog
         `;
         const result = importSingleText(input, 'NonCanine.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -387,10 +502,10 @@ describe('FSHImporter', () => {
         expect(valueSet.components.length).toBe(1);
         assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, [
           {
-            property: VsProperty.CODE,
+            property: 'concept',
             operator: VsOperator.IS_NOT_A,
             value: new FshCode('dog', undefined, undefined)
-              .withLocation([3, 53, 3, 56])
+              .withLocation([3, 56, 3, 59])
               .withFile('NonCanine.fsh')
           }
         ]);
@@ -399,7 +514,7 @@ describe('FSHImporter', () => {
       it('should log an error when the is-not-a filter has a non-code value', () => {
         const input = `
         ValueSet: NonCanineVS
-        * codes from system ZOO where code is-not-a "dog"
+        * codes from system ZOO where concept is-not-a "dog"
         `;
         const result = importSingleText(input, 'NonCanine.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -421,7 +536,7 @@ describe('FSHImporter', () => {
         expect(valueSet.components.length).toBe(1);
         assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, [
           {
-            property: VsProperty.DISPLAY,
+            property: 'display',
             operator: VsOperator.REGEX,
             value: /([Dd]og)|([Cc]anine)/
           }
@@ -445,7 +560,7 @@ describe('FSHImporter', () => {
       it('should parse a value set that uses filter operator in', () => {
         const input = `
         ValueSet: CatAndDogVS
-        * codes from system ZOO where code in "#cat, #dog"
+        * codes from system ZOO where concept in "#cat, #dog"
         `;
         const result = importSingleText(input, 'CatDog.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -453,7 +568,7 @@ describe('FSHImporter', () => {
         expect(valueSet.components.length).toBe(1);
         assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, [
           {
-            property: VsProperty.CODE,
+            property: 'concept',
             operator: VsOperator.IN,
             value: '#cat, #dog'
           }
@@ -463,7 +578,7 @@ describe('FSHImporter', () => {
       it('should log an error when the in filter has a non-string value', () => {
         const input = `
         ValueSet: CatAndDogVS
-        * codes from system ZOO where code in ZOO#cat
+        * codes from system ZOO where concept in ZOO#cat
         `;
         const result = importSingleText(input, 'CatDog.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -477,7 +592,7 @@ describe('FSHImporter', () => {
       it('should parse a value set that uses filter operator not-in', () => {
         const input = `
         ValueSet: NoGooseVS
-        * codes from system ZOO where code not-in "#goose"
+        * codes from system ZOO where concept not-in "#goose"
         `;
         const result = importSingleText(input, 'NoGoose.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -485,7 +600,7 @@ describe('FSHImporter', () => {
         expect(valueSet.components.length).toBe(1);
         assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, [
           {
-            property: VsProperty.CODE,
+            property: 'concept',
             operator: VsOperator.NOT_IN,
             value: '#goose'
           }
@@ -495,7 +610,7 @@ describe('FSHImporter', () => {
       it('should log an error when the not-in filter has a non-string value', () => {
         const input = `
         ValueSet: NoGooseVS
-        * codes from system ZOO where code not-in /duck|goose/
+        * codes from system ZOO where concept not-in /duck|goose/
         `;
         const result = importSingleText(input, 'NoGoose.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -509,7 +624,7 @@ describe('FSHImporter', () => {
       it('should parse a value set that uses filter operator generalizes', () => {
         const input = `
         ValueSet: MustelidVS
-        * codes from system ZOO where code generalizes #mustela-nivalis "least weasel"
+        * codes from system ZOO where concept generalizes #mustela-nivalis "least weasel"
         `;
         const result = importSingleText(input, 'Mustelids.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -517,10 +632,10 @@ describe('FSHImporter', () => {
         expect(valueSet.components.length).toBe(1);
         assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, [
           {
-            property: VsProperty.CODE,
+            property: 'concept',
             operator: VsOperator.GENERALIZES,
             value: new FshCode('mustela-nivalis', undefined, 'least weasel')
-              .withLocation([3, 56, 3, 86])
+              .withLocation([3, 59, 3, 89])
               .withFile('Mustelids.fsh')
           }
         ]);
@@ -529,7 +644,7 @@ describe('FSHImporter', () => {
       it('should log an error when the generalizes filter has a non-code value', () => {
         const input = `
         ValueSet: MustelidVS
-        * codes from system ZOO where code generalizes "least weasel"
+        * codes from system ZOO where concept generalizes "least weasel"
         `;
         const result = importSingleText(input, 'Mustelids.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -552,14 +667,14 @@ describe('FSHImporter', () => {
         expect(valueSet.components.length).toBe(2);
         assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, [
           {
-            property: VsProperty.DISPLAY,
+            property: 'display',
             operator: VsOperator.EXISTS,
             value: true
           }
         ]);
         assertValueSetFilterComponent(valueSet.components[1], 'ZOO', undefined, [
           {
-            property: VsProperty.VERSION,
+            property: 'version',
             operator: VsOperator.EXISTS,
             value: true
           }
@@ -591,12 +706,12 @@ describe('FSHImporter', () => {
         expect(valueSet.components.length).toBe(1);
         assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, [
           {
-            property: VsProperty.VERSION,
+            property: 'version',
             operator: VsOperator.REGEX,
             value: /2\./
           },
           {
-            property: VsProperty.DISPLAY,
+            property: 'display',
             operator: VsOperator.EXISTS,
             value: true
           }
@@ -623,10 +738,10 @@ describe('FSHImporter', () => {
         );
       });
 
-      it('should log an error when a filter has an invalid property', () => {
+      it('should log an error when a filter has an invalid operator', () => {
         const input = `
         ValueSet: ZooVS
-        * codes from system ZOO where animal exists true
+        * codes from system ZOO where display resembles "cat"
         `;
         const result = importSingleText(input, 'Zoo.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -636,10 +751,10 @@ describe('FSHImporter', () => {
         expect(loggerSpy.getLastMessage()).toMatch(/File: Zoo\.fsh.*Line: 3\D/s);
       });
 
-      it('should log an error when a filter has an invalid operator', () => {
+      it('should log an error when a filter (other than the exists filter) has no value', () => {
         const input = `
         ValueSet: ZooVS
-        * codes from system ZOO where display resembles "cat"
+        * codes from system ZOO where display regex
         `;
         const result = importSingleText(input, 'Zoo.fsh');
         expect(result.valueSets.size).toBe(1);
@@ -649,16 +764,16 @@ describe('FSHImporter', () => {
         expect(loggerSpy.getLastMessage()).toMatch(/File: Zoo\.fsh.*Line: 3\D/s);
       });
 
-      it('should log an error when a filter (other than the exists filter) has no value', () => {
+      it('should log an error when a filter component has at least one filter, but no system', () => {
         const input = `
-        ValueSet: ZooVS
-        * codes from system ZOO where display regex
-        `;
+          ValueSet: ZooVS
+          * codes from valueset OtherZooVS where version = "2.0"
+          `;
         const result = importSingleText(input, 'Zoo.fsh');
         expect(result.valueSets.size).toBe(1);
         const valueSet = result.valueSets.get('ZooVS');
         expect(valueSet.components.length).toBe(1);
-        assertValueSetFilterComponent(valueSet.components[0], 'ZOO', undefined, []);
+        assertValueSetFilterComponent(valueSet.components[0], undefined, ['OtherZooVS'], []);
         expect(loggerSpy.getLastMessage()).toMatch(/File: Zoo\.fsh.*Line: 3\D/s);
       });
     });
diff --git a/test/testhelpers/asserts.ts b/test/testhelpers/asserts.ts
index 25fb71e..b65bdc3 100644
--- a/test/testhelpers/asserts.ts
+++ b/test/testhelpers/asserts.ts
@@ -93,13 +93,15 @@ export function assertValueSetConceptComponent(
   component: ValueSetComponent,
   fromSystem: string,
   fromValueSets: string[],
-  concepts: FshCode[]
+  concepts: FshCode[],
+  included = true
 ): void {
   expect(component).toBeInstanceOf(ValueSetConceptComponent);
   const conceptComponent = component as ValueSetConceptComponent;
   expect(conceptComponent.from.system).toBe(fromSystem);
   expect(conceptComponent.from.valueSets).toEqual(fromValueSets);
   expect(conceptComponent.concepts).toEqual(concepts);
+  expect(conceptComponent.inclusion).toBe(included);
 }
 
 export function assertValueSetFilterComponent(