Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Context keyword when defining Extensions #1282

Merged
merged 12 commits into from
Jun 9, 2023
Merged
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}`;
}
cmoesel marked this conversation as resolved.
Show resolved Hide resolved
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 = [
cmoesel marked this conversation as resolved.
Show resolved Hide resolved
...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