Repository: FHIR/sushi (ts)

Issue:
Multi-line descriptions are corrupted in SD 
New line and and carriage return characters are being inserted at strange spots in descriptions. The FSH source code  in PrimaryCancerCondition.  Example from fsh-mcode:

Here's the FSH:

Description: """
Records the history of the primary cancer condition, the original or first tumor in the body (Definition from: [NCI Dictionary of Cancer Terms](https://www.cancer.gov/publications/dictionaries/cancer-terms/def/primary-tumor)). Cancers that are not clearly secondary (i.e., of uncertain origin or behavior) should be documented as primary.

Cancer staging information summarized in this profile should reflect the most recent staging assessment on the patient, and should be updated if and when there is a new staging assessment. Past staging assessments will be preserved in instances of the TNMClinicalStageGroup and/or TNMPathologicalStageGroup, which refer back to PrimaryCancerCondition.

Conformance note: For the code attribute, to be compliant with [US Core Profiles](http://hl7.org/fhir/us/core/STU3/index.html), SNOMED CT must be used unless there is no suitable code, in which case ICD-10-CM can be used.
"""

and here's the resulting description field in SD. Note the \n and \r characters and trucations:


"description": "\necords the history of the primary cancer condition, the original or first tumor in the body (Definition from: [NCI Dictionary of Cancer Terms](https://www.cancer.gov/publications/dictionaries/cancer-terms/def/primary-tumor)). Cancers that are not clearly secondary (i.e., of uncertain origin or behavior) should be documented as primary.\r\n\nancer staging information summarized in this profile should reflect the most recent staging assessment on the patient, and should be updated if and when there is a new staging assessment. Past staging assessments will be preserved in instances of the TNMClinicalStageGroup and/or TNMPathologicalStageGroup, which refer back to PrimaryCancerCondition.\r\n\nonformance note: For the code attribute, to be compliant with [US Core Profiles](http://hl7.org/fhir/us/core/STU3/index.html), SNOMED CT must be used unless there is no suitable code, in which case ICD-10-CM can be used.\r",


Pull request description:
Support CRLF line breaks in multiline strings, clean up blank lines
Fixes #80 and addresses https://standardhealthrecord.atlassian.net/browse/CIMPL-215.

Input files may use either CRLF or LF as their line break. These are the two most common line break styles.
If a line in a multiline string is only whitespace, truncate it so that it becomes an empty line.
If a line is an empty line, don't check it when determining the number of spaces to un-indent each line in the multiline string.

Patch:
diff --git a/src/import/FSHImporter.ts b/src/import/FSHImporter.ts
index 6cfcd9f..a78f5f8 100644
--- a/src/import/FSHImporter.ts
+++ b/src/import/FSHImporter.ts
@@ -1041,30 +1041,39 @@ export class FSHImporter extends FSHVisitor {
 
   /**
    * Multiline strings receive special handling:
-   * - if the first line contains only a newline, toss it
+   * - if the first line contains only whitespace (including newline), toss it
    * - if the last line contains only whitespace (including newline), toss it
-   * - for all other lines, detect the shortest number of leading spaces and always trim that off;
+   * - if another line contains only whitespace, truncate it
+   * - for all other non-whitespace lines, detect the shortest number of leading spaces and always trim that off;
    *   this allows authors to indent a whole block of text, but not have it indented in the output.
    */
   private extractMultilineString(mlStringCtx: ParserRuleContext): string {
     let mlstr = mlStringCtx.getText();
 
-    // first remove leading/trailing """ and leading newline (if applicable)
-    mlstr = mlstr.slice(mlstr[3] === '\n' ? 4 : 3, -3);
+    // remove leading/trailing """
+    mlstr = mlstr.slice(3, -3);
 
     // split into lines so we can process them to determine what leading spaces to trim
-    const lines = mlstr.split('\n');
+    let lines = mlstr.split(/\r?\n/);
+
+    // if the first line is only whitespace, remove it
+    if (lines[0].search(/\S/) === -1) {
+      lines.shift();
+    }
 
     // if the last line is only whitespace, remove it
-    if (lines[lines.length - 1].search(/\S/) === -1) {
+    if (lines.length > 0 && lines[lines.length - 1].search(/\S/) === -1) {
       lines.pop();
     }
 
+    lines = lines.map(l => (/^\s*$/.test(l) ? '' : l));
+
     // find the minimum number of spaces before the first char (ignore zero-length lines)
     let minSpaces = 0;
     lines.forEach(line => {
       const firstNonSpace = line.search(/\S|$/);
-      if (firstNonSpace > 0 && (minSpaces === 0 || firstNonSpace < minSpaces)) {
+      const lineIsEmpty = /^$/.test(line);
+      if (!lineIsEmpty && firstNonSpace > 0 && (minSpaces === 0 || firstNonSpace < minSpaces)) {
         minSpaces = firstNonSpace;
       }
     });


Test patch:
diff --git a/test/import/FSHImporter.test.ts b/test/import/FSHImporter.test.ts
index c3bf640..3222485 100644
--- a/test/import/FSHImporter.test.ts
+++ b/test/import/FSHImporter.test.ts
@@ -139,4 +139,89 @@ describe('FSHImporter', () => {
     expect(result.profiles.size).toBe(1);
     expect(loggerSpy.getAllLogs().length).toBe(0);
   });
+
+  it('should adjust indentation of multi-line strings that include blank lines', () => {
+    const input = `
+    Profile: ObservationProfile
+    Parent: Observation
+    Description: """
+    SUSHI stands for:
+      SUSHI
+      Unshortens
+      Short
+      Hand
+      Inputs
+
+    Recursive acronyms are very popular these days.
+    """`;
+    const expectedDescription = [
+      'SUSHI stands for:',
+      '  SUSHI',
+      '  Unshortens',
+      '  Short',
+      '  Hand',
+      '  Inputs',
+      '',
+      'Recursive acronyms are very popular these days.'
+    ].join('\n');
+    const result = importSingleText(input);
+    const profile = result.profiles.get('ObservationProfile');
+    expect(profile.description).toBe(expectedDescription);
+  });
+
+  it('should truncate whitespace lines in multi-line strings', () => {
+    const input = `
+    Profile: StaircaseObservation
+    Parent: Observation
+    Description: """
+    This
+      Description
+      
+        Looks
+          Like
+          
+            A
+              Staircase
+                """`;
+    const expectedDescription = [
+      'This',
+      '  Description',
+      '',
+      '    Looks',
+      '      Like',
+      '',
+      '        A',
+      '          Staircase'
+    ].join('\n');
+    const result = importSingleText(input);
+    const profile = result.profiles.get('StaircaseObservation');
+    expect(profile.description).toBe(expectedDescription);
+  });
+
+  it('should parse multi-line strings with CRLF line breaks', () => {
+    const input = [
+      'Profile: ObservationProfile',
+      'Parent: Observation',
+      'Description: """',
+      'Line endings',
+      'are always',
+      'troublesome.',
+      '"""'
+    ].join('\r\n');
+    const expectedDescription = ['Line endings', 'are always', 'troublesome.'].join('\n');
+    const result = importSingleText(input);
+    const profile = result.profiles.get('ObservationProfile');
+    expect(profile.description).toBe(expectedDescription);
+  });
+
+  it('should parse multi-line strings that only occupy one line', () => {
+    const input = `
+    Profile: ObservationProfile
+    Parent: Observation
+    Description: """Descriptions come in only one size."""
+    `;
+    const result = importSingleText(input);
+    const profile = result.profiles.get('ObservationProfile');
+    expect(profile.description).toBe('Descriptions come in only one size.');
+  });
 });