Skip to content

Commit

Permalink
Support Context keyword when defining Extensions (#1282)
Browse files Browse the repository at this point in the history
* Import Extensions using Context keyword

The Context keyword is used to specify the contexts in which the
Extension should be used. Add the keyword to the grammar definition.
Store contexts with source info when importing FSH.

* Set context when exporting Extensions

Contexts specified with the Context keyword are applied to Extensions
during export. If the value is quoted, it is a "fhirpath" context. If
the value is not quoted and resolves to an Extension definition, it is
an "extension" context. Otherwise, it is an "element" context.

* Use Context keyword only once

The Context keyword is used at most once per extension. This is similar
to other metadata keywords. To provide multiple contexts, a
comma-separated list can be provided.

* Validate extension context during export

When using the Context keyword on an Extension, validate that the
extension or element specified is something that actually exists. The
element is specified by a FSH path, but is exported as a FHIR element
id. Always use the resource id when the context is an element on a core
FHIR resource. Otherwise, use the URL followed by an element id.

* Find deep contained extensions

A complex extension may contain more than one level of extension
elements. Traverse the extension by url to find deeply nested
extensions.

When an element path is given as context, it may represent a deeply
nested extension. If so, set the context as extension context. The
element counts as a deeply nested extension if every element along its
path is also an extension.

* comment cleanup

* Remove special syntax for contained extensions

Contained extensions must be specified the same as any other element by
using a FSH path.

* Keep parent context if Context keyword is not used

Only set the default context if the Context keyword is unused and there
is no existing context.

Assume that the last # present in an unquoted context is what separates
the URL from the path. This helps with URLs that contain a # character.

* Extension toFSH only writes Context keyword once

* Discard extra whitespace tokens in list of contexts

Remove unused code from Extension toFSH method.
  • Loading branch information
mint-thompson authored Jun 9, 2023
1 parent fa72a64 commit efcb2fa
Show file tree
Hide file tree
Showing 21 changed files with 5,912 additions and 2,276 deletions.
2 changes: 1 addition & 1 deletion antlr/gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
5 changes: 4 additions & 1 deletion antlr/src/main/antlr/FSH.g4
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ entity: alias | profile | extension | invariant | instance | valueSe
alias: KW_ALIAS SEQUENCE EQUAL (SEQUENCE | CODE);

profile: KW_PROFILE name sdMetadata+ sdRule*;
extension: KW_EXTENSION name sdMetadata* sdRule*;
extension: KW_EXTENSION name (sdMetadata | context)* sdRule*;
logical: KW_LOGICAL name sdMetadata* lrRule*;
resource: KW_RESOURCE name sdMetadata* lrRule*;
sdMetadata: parent | id | title | description;
Expand Down Expand Up @@ -65,6 +65,9 @@ instanceOf: KW_INSTANCEOF name;
usage: KW_USAGE CODE;
source: KW_SOURCE name;
target: KW_TARGET STRING;
context: KW_CONTEXT contextItem* lastContextItem;
contextItem: QUOTED_CONTEXT | UNQUOTED_CONTEXT;
lastContextItem: LAST_QUOTED_CONTEXT | LAST_UNQUOTED_CONTEXT;


// RULES
Expand Down
10 changes: 8 additions & 2 deletions antlr/src/main/antlr/FSHLexer.g4
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ KW_SEVERITY: 'Severity' WS* ':';
KW_USAGE: 'Usage' WS* ':';
KW_SOURCE: 'Source' WS* ':';
KW_TARGET: 'Target' WS* ':';
KW_CONTEXT: 'Context' WS* ':' -> pushMode(LIST_OF_CONTEXTS);
KW_MOD: '?!';
KW_MS: 'MS';
KW_SU: 'SU';
Expand Down Expand Up @@ -100,8 +101,6 @@ CARET_SEQUENCE: '^' NONWS+;
// '/' EXPRESSION '/'
REGEX: '/' ('\\/' | ~[*/\r\n])('\\/' | ~[/\r\n])* '/';
PARAMETER_DEF_LIST: '(' (SEQUENCE WS* COMMA WS*)* SEQUENCE ')';
// BLOCK_COMMENT must precede SEQUENCE so that a block comment without whitespace does not become a SEQUENCE
BLOCK_COMMENT: '/*' .*? '*/' -> skip;
// NON-WHITESPACE
Expand All @@ -128,3 +127,10 @@ BRACKETED_PARAM: WS* '[[' ( ~[\]] | (']'~[\]]) | (']]' WS* ~[,)]) )+ ']]' WS* ',
LAST_BRACKETED_PARAM: WS* '[[' ( ~[\]] | (']'~[\]]) | (']]' WS* ~[,)]) )+ ']]' WS* ')' -> popMode, popMode;
PLAIN_PARAM: WS* ('\\)' | '\\,' | '\\\\' | ~[),])* WS* ',';
LAST_PLAIN_PARAM: WS* ('\\)' | '\\,' | '\\\\' | ~[),])* WS* ')' -> popMode, popMode;

mode LIST_OF_CONTEXTS;
QUOTED_CONTEXT: STRING WS* ',';
LAST_QUOTED_CONTEXT: STRING -> popMode;
UNQUOTED_CONTEXT: (SEQUENCE | CODE) WS* ',';
LAST_UNQUOTED_CONTEXT: (SEQUENCE | CODE) -> popMode;
CONTEXT_WHITESPACE: WS -> channel(HIDDEN);
155 changes: 138 additions & 17 deletions src/export/StructureDefinitionExporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ import {
Resource,
Instance,
FshCode,
FshEntity
FshEntity,
ExtensionContext
} from '../fshtypes';
import { FSHTank } from '../import';
import { InstanceExporter } from '../export';
Expand Down Expand Up @@ -383,27 +384,13 @@ export class StructureDefinitionExporter implements Fishable {
: 'constraint';

if (fshDefinition instanceof Extension) {
// context and contextInvariant only apply to extensions.
// Keep context, assuming context is still valid for child extensions.
// Keep contextInvariant, assuming context is still valid for child extensions.

// Automatically set url.fixedUri on Extensions unless it is already set
const url = structDef.findElement('Extension.url');
if (url.fixedUri == null) {
url.fixedUri = structDef.url;
}
if (structDef.context == null) {
// Set context to everything by default, but users can override w/ rules, e.g.
// ^context[0].type = #element
// ^context[0].expression = "Patient"
// TODO: Consider introducing metadata keywords for this
structDef.context = [
{
type: 'element',
expression: 'Element'
}
];
}
// context and contextInvariant only apply to extensions.
this.setContext(structDef, fshDefinition);
} else {
// Should not be defined for non-extensions, but clear just to be sure
delete structDef.context;
Expand All @@ -420,6 +407,140 @@ export class StructureDefinitionExporter implements Fishable {
});
}

private setContext(structDef: StructureDefinition, fshDefinition: Extension) {
// Set context to everything by default, but users can override w/ Context keyword or rules, e.g.
// Context: Observation
// ^context[0].type = #element
// ^context[0].expression = "Patient"
if (fshDefinition.contexts.length > 0) {
structDef.context = [];
fshDefinition.contexts.forEach(extContext => {
if (extContext.isQuoted) {
structDef.context.push({
expression: extContext.value,
type: 'fhirpath'
});
} else {
// this could be the path to an element on some StructureDefinition
// it could be specified either as a url, a path, or a url with a path
// url: http://example.org/Some/Url
// path: resource-name-or-id.some.path
// url with path: http://example.org/Some/Url#some.path
// we split on # to make contextItem and contextPath, so if contextPath is not empty, we have a url with a path
// otherwise, check if contextItem is a url or not. if it is not a url, we have a fsh path that starts with a name or id
// since # can appear in a url, assume that the last # is what separates the url from the FSH path
const splitContext = extContext.value.split('#');
let contextItem: string;
let contextPath: string;
if (splitContext.length === 1) {
contextPath = '';
contextItem = splitContext[0];
} else {
contextPath = splitContext.pop();
contextItem = splitContext.join('#');
}

if (contextPath === '' && !isUri(contextItem)) {
const splitPath = splitOnPathPeriods(contextItem);
contextItem = splitPath[0];
contextPath = splitPath.slice(1).join('.');
}
const targetResource = this.fishForFHIR(
contextItem,
Type.Extension,
Type.Profile,
Type.Resource,
Type.Logical,
Type.Type
);
if (targetResource != null) {
const resourceSD = StructureDefinition.fromJSON(targetResource);
const targetElement = resourceSD.findElementByPath(contextPath, this);
if (targetElement != null) {
// we want to represent the context using "extension" type whenever possible.
// if the resource is an Extension, and every element along the path to the target
// has type "extension", then this context can be represented with type "extension".
// otherwise, this is "element" context.
if (targetElement.isPartOfComplexExtension()) {
this.setContextForComplexExtension(structDef, targetElement, extContext);
} else {
let contextExpression: string;
let contextType = 'element';
if (resourceSD.type === 'Extension' && targetElement.parent() == null) {
contextExpression = resourceSD.url;
contextType = 'extension';
} else if (
resourceSD.derivation === 'specialization' &&
resourceSD.url.startsWith('http://hl7.org/fhir/StructureDefinition/')
) {
contextExpression = targetElement.id;
} else {
contextExpression = `${resourceSD.url}#${targetElement.id}`;
}
structDef.context.push({
expression: contextExpression,
type: contextType
});
}
} else {
logger.error(
`Could not find element ${contextPath} on resource ${contextItem} to use as extension context.`,
extContext.sourceInfo
);
}
} else {
logger.error(
`Could not find resource ${contextItem} to use as extension context.`,
extContext.sourceInfo
);
}
}
});
} else if (structDef.context == null) {
// only set default context if there's no inherited context
structDef.context = [
{
type: 'element',
expression: 'Element'
}
];
}
}

/**
* When setting context for a complex extension, the path to the contained extension
* is based on the urls for each contained extension, like this:
* extensionUrl#childExtension.grandchildExtension
* See https://chat.fhir.org/#narrow/stream/179252-IG-creation/topic/Extension.20Contexts/near/361378342
*/
private setContextForComplexExtension(
structDef: StructureDefinition,
targetElement: ElementDefinition,
extContext: ExtensionContext
) {
const extensionHierarchy = [
...targetElement.getAllParents().slice(0, -1).reverse(),
targetElement
]
.map(ed => {
const myUrl = ed.structDef.findElement(`${ed.id}.url`);
if (myUrl?.fixedUri != null) {
return myUrl.fixedUri;
} else {
logger.error(
`Could not find URL for extension ${ed.id} as part of extension context.`,
extContext.sourceInfo
);
return '';
}
})
.join('.');
structDef.context.push({
expression: `${targetElement.structDef.url}#${extensionHierarchy}`,
type: 'extension'
});
}

/**
* At this point, 'structDef' contains the parent's ElementDefinitions. For profiles
* and extensions, these ElementDefinitions are already correct, so no processing is
Expand Down
9 changes: 9 additions & 0 deletions src/fhirtypes/ElementDefinition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2667,6 +2667,15 @@ export class ElementDefinition {
return slice;
}

isPartOfComplexExtension(): boolean {
return (
this.structDef.type === 'Extension' &&
[this, ...this.getAllParents().slice(0, -1)].every(
ed => ed.type?.length === 1 && ed.type[0].code === 'Extension' && ed.sliceName != null
)
);
}

/**
* Clones the current ElementDefinition, optionally clearing the stored "original" (clears it by default)
* @param {boolean} [clearOriginal=true] - indicates if the stored "original" should be cleared
Expand Down
24 changes: 24 additions & 0 deletions src/fshtypes/Extension.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,48 @@
import { SourceInfo } from './FshEntity';
import { FshStructure } from './FshStructure';
import { fshifyString } from './common';
import { SdRule } from './rules';
import { EOL } from 'os';

export class Extension extends FshStructure {
rules: SdRule[];
contexts: ExtensionContext[];

constructor(public name: string) {
super(name);
// Init the parent to 'Extension', as this is what 99% of extensions do.
// This can still be overridden via the FSH syntax (using Parent: keyword).
this.parent = 'Extension'; // init to 'Extension'
this.contexts = [];
}

get constructorName() {
return 'Extension';
}

metadataToFSH(): string {
const sdMetadata = super.metadataToFSH();
const contextValue = this.contexts
.map(extContext =>
extContext.isQuoted ? `"${fshifyString(extContext.value)}"` : extContext.value
)
.join(', ');
if (contextValue.length > 0) {
return `${sdMetadata}${EOL}Context: ${contextValue}`;
} else {
return sdMetadata;
}
}

toFSH(): string {
const metadataFSH = this.metadataToFSH();
const rulesFSH = this.rules.map(r => r.toFSH()).join(EOL);
return `${metadataFSH}${rulesFSH.length ? EOL + rulesFSH : ''}`;
}
}

export type ExtensionContext = {
value: string;
isQuoted: boolean;
sourceInfo?: SourceInfo;
};
64 changes: 63 additions & 1 deletion src/import/FSHImporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ import {
RuleSet,
ParamRuleSet,
Mapping,
isInstanceUsage
isInstanceUsage,
ExtensionContext
} from '../fshtypes';
import {
CardRule,
Expand Down Expand Up @@ -304,6 +305,16 @@ export class FSHImporter extends FSHVisitor {
});
} else {
this.parseProfileOrExtension(extension, ctx.sdMetadata(), ctx.sdRule());
ctx.context().forEach(extContext => {
if (extension.contexts?.length > 0) {
logger.error("Metadata field 'Context' already declared.", {
file: this.currentFile,
location: this.extractStartStop(extContext)
});
} else {
extension.contexts = this.visitContext(extContext);
}
});
this.currentDoc.extensions.set(extension.name, extension);
}
}
Expand Down Expand Up @@ -938,6 +949,53 @@ export class FSHImporter extends FSHVisitor {
return this.extractString(ctx.STRING());
}

visitContext(ctx: pc.ContextContext): ExtensionContext[] {
const contexts: ExtensionContext[] = [];
ctx.contextItem().forEach(contextItem => {
if (contextItem.QUOTED_CONTEXT()) {
contexts.push({
value: this.unescapeQuotedString(
contextItem.QUOTED_CONTEXT().getText().slice(0, -1).trim()
),
isQuoted: true,
sourceInfo: {
file: this.currentFile,
location: this.extractStartStop(contextItem.QUOTED_CONTEXT())
}
});
} else {
contexts.push({
value: contextItem.UNQUOTED_CONTEXT().getText().slice(0, -1).trim(),
isQuoted: false,
sourceInfo: {
file: this.currentFile,
location: this.extractStartStop(contextItem.UNQUOTED_CONTEXT())
}
});
}
});
if (ctx.lastContextItem().LAST_QUOTED_CONTEXT()) {
contexts.push({
value: this.unescapeQuotedString(ctx.lastContextItem().LAST_QUOTED_CONTEXT().getText()),
isQuoted: true,
sourceInfo: {
file: this.currentFile,
location: this.extractStartStop(ctx.lastContextItem().LAST_QUOTED_CONTEXT())
}
});
} else {
contexts.push({
value: ctx.lastContextItem().LAST_UNQUOTED_CONTEXT().getText(),
isQuoted: false,
sourceInfo: {
file: this.currentFile,
location: this.extractStartStop(ctx.lastContextItem().LAST_UNQUOTED_CONTEXT())
}
});
}
return contexts;
}

private parseCodeLexeme(conceptText: string, parentCtx: ParserRuleContext): FshCode {
const concept = parseCodeLexeme(conceptText);
if (concept.system?.length > 0) {
Expand Down Expand Up @@ -2215,6 +2273,10 @@ export class FSHImporter extends FSHVisitor {

private extractString(stringCtx: ParserRuleContext): string {
const str = stringCtx?.getText() ?? '""'; // default to empty string if stringCtx is null
return this.unescapeQuotedString(str);
}

private unescapeQuotedString(str: string): string {
const strNoQuotes = str.slice(1, str.length - 1); // Strip surrounding quotes

// Replace escaped characters
Expand Down
Loading

0 comments on commit efcb2fa

Please sign in to comment.