Repository: danvk/literate-ts (ts)

Issue:
Report error spans within code samples where possible
With a VS Code task, you get errors at the start of code blocks:
<img width="623" alt="image" src="https://github.com/danvk/literate-ts/assets/98301/ec4abf74-6665-42cb-aa86-435d9cb04955">

It would be nicer if you got errors in the exact place where they occurred in the code block. Hopefully this just involves adding the line number of the code block to whatever error position TypeScript reports. Then you'd have to subtract the lengths of each prepended block.

Pull request description:
Report error spans in code samples
Fixes #99 

This almost feels like you've got `tsc` running on your Asciidoc!

<img width="743" alt="image" src="https://github.com/danvk/literate-ts/assets/98301/acb1df93-faa2-4a34-a0a6-08f36f8b99ca">

One annoyance is that it's impossible for a VS Code `problemMatcher` to match multiline errors, so failing type assertions don't print the actual / expected. See https://github.com/microsoft/vscode/issues/9635 for the sad story.

<img width="558" alt="image" src="https://github.com/danvk/literate-ts/assets/98301/01d2e381-5b90-4f9a-bb98-750e23bb1300">

This makes a backwards-incompatible change to the cache format. But since I just released 2.0.0 yesterday, I can't imagine anyone other than me is depending on it.

Patch:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index c691ac5..2a2ab4e 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -9,5 +9,8 @@
   "typescript.tsdk": "node_modules/typescript/lib",
   "[typescript]": {
     "editor.formatOnSave": true,
-  }
+  },
+  "cSpell.words": [
+    "nums"
+  ]
 }
\ No newline at end of file
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
new file mode 100644
index 0000000..ba3c157
--- /dev/null
+++ b/.vscode/tasks.json
@@ -0,0 +1,47 @@
+{
+  // See https://go.microsoft.com/fwlink/?LinkId=733558
+  // for the documentation about the tasks.json format
+  "version": "2.0.0",
+  "tasks": [
+    {
+      "label": "Run file through literate-ts",
+      "type": "shell",
+      "command": "yarn ts-node index.ts ${file}",
+      "problemMatcher": [
+        {
+          "base": "$tsc-watch"
+        },
+        {
+          "owner": "literate-ts",
+          "fileLocation": "absolute",
+          "pattern": [
+            {
+              "regexp": "^💥 (.*?):(\\d+):(\\d+)-(\\d+): (.*)$",
+              "file": 1,
+              "line": 2,
+              "column": 3,
+              "endColumn": 4,
+              "message": 5,
+            },
+            // This doesn't work, see https://github.com/microsoft/vscode/issues/9635
+            // {
+            //   "regexp": "^ *(?:Expected|Actual): (.*)$",
+            //   "message": 1,
+            //   "loop": true
+            // }
+          ]
+        },
+        {
+          "owner": "literate-ts",
+          "fileLocation": "absolute",
+          "pattern": {
+            "regexp": "^💥 (.*?):(\\d+): (.*)$",
+            "file": 1,
+            "line": 2,
+            "message": 3
+          }
+        }
+      ]
+    }
+  ]
+}
diff --git a/src/code-sample.ts b/src/code-sample.ts
index 171e401..8a45506 100644
--- a/src/code-sample.ts
+++ b/src/code-sample.ts
@@ -7,6 +7,7 @@ import {extractMarkdownSamples} from './markdown.js';
 import {generateIdMetadata} from './metadata.js';
 
 export interface Processor {
+  /** Let the processor know about the current line number (0-based). */
   setLineNum(line: number): void;
   setHeader(header: string): void;
   setDirective(directive: string): void;
@@ -122,7 +123,9 @@ function process(
             isTSX: nextIsTSX,
             checkJS: nextShouldCheckJs,
             sourceFile,
+            lineNumber: lineNum,
             tsOptions: {...tsOptions},
+            prefixesLength: 0,
           });
         }
         if (prependNext) {
@@ -183,7 +186,7 @@ Stripped source file sample:
 ${strippedSource.trim()}
 ----
       `.trimStart(),
-      sample,
+      {sample},
     );
     return false;
   }
@@ -205,10 +208,10 @@ export function applyPrefixes(samples: PrefixedCodeSample[]): CodeSample[] {
       throw new Error(`Logic error: sample {sample.id} was not replaced.`);
     }
     const prefixes = sample.id.endsWith('-output') ? [] : sample.prefixes;
-    const content = prefixes
-      .map(({id, lines}) => sliceLines(idToSample[id].content, lines))
-      .concat([sample.content])
-      .join('\n');
+    const combinedPrefixes = prefixes.map(({id, lines}) =>
+      sliceLines(idToSample[id].content, lines),
+    );
+    const content = combinedPrefixes.concat([sample.content]).join('\n');
     return {
       descriptor: sample.descriptor,
       id: sample.id,
@@ -219,6 +222,8 @@ export function applyPrefixes(samples: PrefixedCodeSample[]): CodeSample[] {
       isTSX: sample.isTSX,
       checkJS: sample.checkJS,
       sourceFile: sample.sourceFile,
+      lineNumber: sample.lineNumber,
+      prefixesLength: _.sum(combinedPrefixes.map(p => p.split('\n').length)),
       content,
     };
   });
diff --git a/src/metadata.ts b/src/metadata.ts
index 47ee6db..646a67c 100644
--- a/src/metadata.ts
+++ b/src/metadata.ts
@@ -4,7 +4,7 @@ import {IdMetadata} from './types.js';
 export function generateIdMetadata(id: string, sourceFile: string, line: number): IdMetadata {
   const file = path.isAbsolute(sourceFile) ? sourceFile : `./${sourceFile}`;
   return {
-    descriptor: `${file}:${line}`,
+    descriptor: `${file}:${1 + line}`,
     id,
   };
 }
diff --git a/src/ts-checker.ts b/src/ts-checker.ts
index a8823d5..5efdf2c 100644
--- a/src/ts-checker.ts
+++ b/src/ts-checker.ts
@@ -7,7 +7,7 @@ import path from 'path';
 import ts from 'typescript';
 
 import {log} from './logger.js';
-import {fail, getLastFailReason} from './test-tracker.js';
+import {FailureContext, FailureLocation, fail, getLastFailReason} from './test-tracker.js';
 import {writeTempFile, matchAndExtract, getTempDir, matchAll, sha256, tuple} from './utils.js';
 import {CodeSample} from './types.js';
 import {ExecErrorType, runNode} from './node-runner.js';
@@ -136,14 +136,14 @@ function checkMatchingErrors(expectedErrorsIn: TypeScriptError[], actualErrors:
       }
     } else {
       const {line, start, end, message} = error;
-      fail(`Unexpected TypeScript error: ${line}:${start}-${end}: ${message}`);
+      fail(`Unexpected TypeScript error: ${message}`, {location: {line, start, end}});
       anyFailures = true;
     }
   }
 
   for (const error of expectedErrors) {
     const {line, start, end, message} = error;
-    fail(`Expected TypeScript error was not produced: ${line}:${start}-${end}: ${message}`);
+    fail(`Expected TypeScript error was not produced: ${message}`, {location: {line, start, end}});
     anyFailures = true;
   }
 
@@ -301,11 +301,20 @@ export function checkExpectTypeAssertions(
         const actualType = checker.typeToString(type, nodeForType);
 
         if (!typesMatch(assertion.type, actualType)) {
+          const {character: start} = source.getLineAndCharacterOfPosition(nodeForType.getStart());
+          const {character: end} = source.getLineAndCharacterOfPosition(nodeForType.getEnd());
           const testedText = node !== nodeForType ? ` (tested \`${nodeForType.getText()}\`)` : '';
           fail(
             `Failed type assertion for \`${node.getText()}\`${testedText}\n` +
               `  Expected: ${assertion.type}\n` +
               `    Actual: ${actualType}`,
+            {
+              location: {
+                line,
+                start,
+                end,
+              },
+            },
           );
           anyFailures = true;
         } else {
@@ -476,7 +485,7 @@ export function getLanguageServiceHost(program: ts.Program): ts.LanguageServiceH
 
 export interface CheckTsResult {
   passed: boolean;
-  failReason?: string;
+  failure?: {message: string; location?: FailureLocation};
   output?: ExecErrorType;
   // TODO: include more details about errors
 }
@@ -513,15 +522,16 @@ export async function checkTs(
   if (hit && !options.skipCache) {
     const result = await fs.readFile(tempFilePath, 'utf8');
     const {key: _, ...out} = JSON.parse(result) as CheckTsResult & {key: unknown};
-    if (out.failReason) {
-      fail(out.failReason);
+    if (out.failure) {
+      const {message, ...context} = out.failure;
+      fail(message, context);
     }
     return out;
   }
 
   const result = await uncachedCheckTs(sample, runCode, config);
   if (result.passed === false) {
-    result.failReason = getLastFailReason() ?? undefined;
+    result.failure = getLastFailReason() ?? undefined;
   }
 
   if (!options.skipCache) {
diff --git a/src/types.ts b/src/types.ts
index 583f7da..bd8d436 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -15,7 +15,10 @@ export interface CodeSample {
   checkJS: boolean;
   tsOptions: CompilerOptions;
   sourceFile: string;
+  lineNumber: number;
   nodeModules: readonly string[];
+  /** Combined length of prefixes, for offsetting error messages */
+  prefixesLength: number;
   output?: SampleOutput;
 }
 


Test patch:
diff --git a/src/test-tracker.ts b/src/test-tracker.ts
index 0e0c2fe..b35140a 100644
--- a/src/test-tracker.ts
+++ b/src/test-tracker.ts
@@ -1,3 +1,4 @@
+import _ from 'lodash';
 import {log, isLoggingToStderr} from './logger.js';
 import {CodeSample} from './types.js';
 
@@ -32,14 +33,36 @@ export function finishSample() {
   lastFailReason = null;
 }
 
-let lastFailReason: string | null = null;
-export function fail(message: string, sample?: CodeSample) {
+export interface FailureLocation {
+  line: number;
+  start: number;
+  end: number;
+}
+
+export interface FailureContext {
+  sample?: CodeSample;
+  location?: FailureLocation;
+}
+
+let lastFailReason: {message: string; location?: FailureLocation} | null = null;
+export function fail(message: string, context: FailureContext = {}) {
+  let {sample} = context;
   if (sample === undefined) {
     sample = currentSample;
   }
-  lastFailReason = message;
+  lastFailReason = {message, location: context.location};
+
+  let {location} = context;
+  if (location && sample) {
+    // Change to a location in the source file. The +1 is for 1-based line numbers.
+    location = {...location, line: location.line + sample.lineNumber - sample.prefixesLength + 1};
+  }
 
-  const fullMessage = `💥 ${sample?.descriptor}: ${message}`;
+  const fullMessage = location
+    ? `💥 ${sample?.sourceFile}:${location.line}:${1 + location.start}-${
+        1 + location.end
+      }: ${message}`
+    : `💥 ${sample?.descriptor}: ${message}`;
   if (!isLoggingToStderr()) {
     console.error('\n' + fullMessage);
   }
@@ -49,7 +72,7 @@ export function fail(message: string, sample?: CodeSample) {
   }
 }
 
-export function getLastFailReason(): string | null {
+export function getLastFailReason() {
   return lastFailReason;
 }
 
diff --git a/src/test/__snapshots__/asciidoc.test.ts.snap b/src/test/__snapshots__/asciidoc.test.ts.snap
index 6fbbffc..cadad95 100644
--- a/src/test/__snapshots__/asciidoc.test.ts.snap
+++ b/src/test/__snapshots__/asciidoc.test.ts.snap
@@ -7,12 +7,14 @@ exports[`extractSamples snapshot: backticks 1`] = `
     "content": "function greet(who: string) {
   console.log('Hello', who);
 }",
-    "descriptor": "./backticks.asciidoc:2",
+    "descriptor": "./backticks.asciidoc:3",
     "id": "greet-ts",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 4,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "backticks.asciidoc",
@@ -21,12 +23,14 @@ exports[`extractSamples snapshot: backticks 1`] = `
   {
     "checkJS": false,
     "content": "export const Example = () => <div />;",
-    "descriptor": "./backticks.asciidoc:11",
+    "descriptor": "./backticks.asciidoc:12",
     "id": "greet-tsx",
     "isTSX": false,
     "language": "tsx",
+    "lineNumber": 13,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "backticks.asciidoc",
@@ -35,12 +39,14 @@ exports[`extractSamples snapshot: backticks 1`] = `
   {
     "checkJS": false,
     "content": "console.log("end of file");",
-    "descriptor": "./backticks.asciidoc:26",
+    "descriptor": "./backticks.asciidoc:27",
     "id": "backticks-26",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 26,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "backticks.asciidoc",
@@ -56,12 +62,14 @@ exports[`extractSamples snapshot: commented-sample 1`] = `
   {
     "checkJS": false,
     "content": "function somethingDangerous() {}",
-    "descriptor": "./commented-sample.asciidoc:6",
+    "descriptor": "./commented-sample.asciidoc:7",
     "id": "commented-sample-6",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 6,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "commented-sample.asciidoc",
@@ -78,16 +86,18 @@ try {
   console.warn('alas!');
 }
 val  // Type is number | null",
-    "descriptor": "./commented-sample.asciidoc:12",
+    "descriptor": "./commented-sample.asciidoc:13",
     "id": "commented-sample-12",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 12,
     "nodeModules": [],
     "prefixes": [
       {
         "id": "commented-sample-6",
       },
     ],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "commented-sample.asciidoc",
@@ -103,12 +113,14 @@ exports[`extractSamples snapshot: doc1 1`] = `
     "content": "function greet(who: string) {
   console.log('Hello', who);
 }",
-    "descriptor": "./doc1.asciidoc:2",
+    "descriptor": "./doc1.asciidoc:3",
     "id": "greet-ts",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 5,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "doc1.asciidoc",
@@ -124,12 +136,14 @@ exports[`extractSamples snapshot: inline-replacement 1`] = `
     "content": "function calculateAge(birthDate: Date): number {
   // ...
 }",
-    "descriptor": "./inline-replacement.asciidoc:5",
+    "descriptor": "./inline-replacement.asciidoc:6",
     "id": "inline-replacement-5",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 5,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": "calculate-age-replacement",
     "sectionHeader": null,
     "sourceFile": "inline-replacement.asciidoc",
@@ -142,12 +156,14 @@ exports[`extractSamples snapshot: inline-replacement 1`] = `
   return 0;
   // END
 }",
-    "descriptor": "./inline-replacement.asciidoc:11",
+    "descriptor": "./inline-replacement.asciidoc:12",
     "id": "calculate-age-replacement",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 14,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "inline-replacement.asciidoc",
@@ -158,12 +174,14 @@ exports[`extractSamples snapshot: inline-replacement 1`] = `
     "content": "function calculateAge(birthDate: string): number {
   // ...
 }",
-    "descriptor": "./inline-replacement.asciidoc:27",
+    "descriptor": "./inline-replacement.asciidoc:28",
     "id": "inline-replacement-27",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 27,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": "calculate-age-replacement",
     "sectionHeader": null,
     "sourceFile": "inline-replacement.asciidoc",
@@ -181,12 +199,14 @@ exports[`extractSamples snapshot: multilinetype 1`] = `
 //   x: number;
 //   y: number;
 // }",
-    "descriptor": "./multilinetype.asciidoc:3",
+    "descriptor": "./multilinetype.asciidoc:4",
     "id": "multilinetype-3",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 3,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "multilinetype.asciidoc",
@@ -199,12 +219,14 @@ exports[`extractSamples snapshot: multilinetype 1`] = `
   // ...
   return c;
 }",
-    "descriptor": "./multilinetype.asciidoc:13",
+    "descriptor": "./multilinetype.asciidoc:14",
     "id": "multilinetype-13",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 13,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "multilinetype.asciidoc",
@@ -218,12 +240,14 @@ exports[`extractSamples snapshot: noid 1`] = `
   {
     "checkJS": false,
     "content": "console.log('Hello', 'TS');",
-    "descriptor": "./noid.asciidoc:9",
+    "descriptor": "./noid.asciidoc:10",
     "id": "noid-9",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 9,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "noid.asciidoc",
@@ -237,12 +261,14 @@ exports[`extractSamples snapshot: prepend 1`] = `
   {
     "checkJS": false,
     "content": "type AB = 'a' | 'b';",
-    "descriptor": "./prepend.asciidoc:1",
+    "descriptor": "./prepend.asciidoc:2",
     "id": "prefix",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 4,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend.asciidoc",
@@ -251,16 +277,18 @@ exports[`extractSamples snapshot: prepend 1`] = `
   {
     "checkJS": false,
     "content": "const a: AB = 'a';",
-    "descriptor": "./prepend.asciidoc:7",
+    "descriptor": "./prepend.asciidoc:8",
     "id": "combined",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 10,
     "nodeModules": [],
     "prefixes": [
       {
         "id": "prefix",
       },
     ],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend.asciidoc",
@@ -269,12 +297,14 @@ exports[`extractSamples snapshot: prepend 1`] = `
   {
     "checkJS": false,
     "content": "const a: AB = 'a';",
-    "descriptor": "./prepend.asciidoc:15",
+    "descriptor": "./prepend.asciidoc:16",
     "id": "final",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 18,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend.asciidoc",
@@ -288,12 +318,14 @@ exports[`extractSamples snapshot: prepend-multiple 1`] = `
   {
     "checkJS": false,
     "content": "type AB = 'a' | 'b';",
-    "descriptor": "./prepend-multiple.asciidoc:3",
+    "descriptor": "./prepend-multiple.asciidoc:4",
     "id": "prepend-multiple-3",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 3,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend-multiple.asciidoc",
@@ -302,16 +334,18 @@ exports[`extractSamples snapshot: prepend-multiple 1`] = `
   {
     "checkJS": false,
     "content": "type ABC = AB | 'c';",
-    "descriptor": "./prepend-multiple.asciidoc:9",
+    "descriptor": "./prepend-multiple.asciidoc:10",
     "id": "prepend-multiple-9",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 9,
     "nodeModules": [],
     "prefixes": [
       {
         "id": "prepend-multiple-3",
       },
     ],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend-multiple.asciidoc",
@@ -320,10 +354,11 @@ exports[`extractSamples snapshot: prepend-multiple 1`] = `
   {
     "checkJS": false,
     "content": "const c: ABC = 'c';",
-    "descriptor": "./prepend-multiple.asciidoc:14",
+    "descriptor": "./prepend-multiple.asciidoc:15",
     "id": "prepend-multiple-14",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 14,
     "nodeModules": [],
     "prefixes": [
       {
@@ -333,6 +368,7 @@ exports[`extractSamples snapshot: prepend-multiple 1`] = `
         "id": "prepend-multiple-9",
       },
     ],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend-multiple.asciidoc",
diff --git a/src/test/__snapshots__/markdown.test.ts.snap b/src/test/__snapshots__/markdown.test.ts.snap
index 552054e..bc1a472 100644
--- a/src/test/__snapshots__/markdown.test.ts.snap
+++ b/src/test/__snapshots__/markdown.test.ts.snap
@@ -5,12 +5,14 @@ exports[`markdown should match snapshots: commented-sample 1`] = `
   {
     "checkJS": false,
     "content": "function somethingDangerous() {}",
-    "descriptor": "./commented-sample.md:5",
+    "descriptor": "./commented-sample.md:6",
     "id": "commented-sample-5",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 5,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "commented-sample.md",
@@ -27,16 +29,18 @@ try {
   console.warn('alas!');
 }
 val  // Type is number | null",
-    "descriptor": "./commented-sample.md:10",
+    "descriptor": "./commented-sample.md:11",
     "id": "commented-sample-10",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 10,
     "nodeModules": [],
     "prefixes": [
       {
         "id": "commented-sample-5",
       },
     ],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "commented-sample.md",
@@ -52,12 +56,14 @@ exports[`markdown should match snapshots: doc1 1`] = `
     "content": "function greet(who: string) {
   console.log('Hello', who);
 }",
-    "descriptor": "./doc1.md:2",
+    "descriptor": "./doc1.md:3",
     "id": "greet-ts",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 4,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "doc1.md",
@@ -67,12 +73,14 @@ exports[`markdown should match snapshots: doc1 1`] = `
     "checkJS": false,
     "content": "console.log("end of file");
 ",
-    "descriptor": "./doc1.md:19",
+    "descriptor": "./doc1.md:20",
     "id": "doc1-19",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 19,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "doc1.md",
@@ -90,12 +98,14 @@ exports[`markdown should match snapshots: multilinetype 1`] = `
 //   x: number;
 //   y: number;
 // }",
-    "descriptor": "./multilinetype.md:3",
+    "descriptor": "./multilinetype.md:4",
     "id": "multilinetype-3",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 3,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "multilinetype.md",
@@ -108,12 +118,14 @@ exports[`markdown should match snapshots: multilinetype 1`] = `
   // ...
   return c;
 }",
-    "descriptor": "./multilinetype.md:13",
+    "descriptor": "./multilinetype.md:14",
     "id": "multilinetype-13",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 13,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "multilinetype.md",
@@ -127,12 +139,14 @@ exports[`markdown should match snapshots: noid 1`] = `
   {
     "checkJS": false,
     "content": "console.log('Hello', 'TS');",
-    "descriptor": "./noid.md:7",
+    "descriptor": "./noid.md:8",
     "id": "noid-7",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 7,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "noid.md",
@@ -146,12 +160,14 @@ exports[`markdown should match snapshots: prepend 1`] = `
   {
     "checkJS": false,
     "content": "type AB = 'a' | 'b';",
-    "descriptor": "./prepend.md:1",
+    "descriptor": "./prepend.md:2",
     "id": "prefix",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 3,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend.md",
@@ -160,16 +176,18 @@ exports[`markdown should match snapshots: prepend 1`] = `
   {
     "checkJS": false,
     "content": "const a: AB = 'a';",
-    "descriptor": "./prepend.md:6",
+    "descriptor": "./prepend.md:7",
     "id": "combined",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 8,
     "nodeModules": [],
     "prefixes": [
       {
         "id": "prefix",
       },
     ],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend.md",
@@ -178,12 +196,14 @@ exports[`markdown should match snapshots: prepend 1`] = `
   {
     "checkJS": false,
     "content": "const a: AB = 'a';",
-    "descriptor": "./prepend.md:13",
+    "descriptor": "./prepend.md:14",
     "id": "final",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 15,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend.md",
@@ -197,12 +217,14 @@ exports[`markdown should match snapshots: prepend-multiple 1`] = `
   {
     "checkJS": false,
     "content": "type AB = 'a' | 'b';",
-    "descriptor": "./prepend-multiple.md:2",
+    "descriptor": "./prepend-multiple.md:3",
     "id": "prepend-multiple-2",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 2,
     "nodeModules": [],
     "prefixes": [],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend-multiple.md",
@@ -211,16 +233,18 @@ exports[`markdown should match snapshots: prepend-multiple 1`] = `
   {
     "checkJS": false,
     "content": "type ABC = AB | 'c';",
-    "descriptor": "./prepend-multiple.md:7",
+    "descriptor": "./prepend-multiple.md:8",
     "id": "prepend-multiple-7",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 7,
     "nodeModules": [],
     "prefixes": [
       {
         "id": "prepend-multiple-2",
       },
     ],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend-multiple.md",
@@ -229,10 +253,11 @@ exports[`markdown should match snapshots: prepend-multiple 1`] = `
   {
     "checkJS": false,
     "content": "const c: ABC = 'c';",
-    "descriptor": "./prepend-multiple.md:11",
+    "descriptor": "./prepend-multiple.md:12",
     "id": "prepend-multiple-11",
     "isTSX": false,
     "language": "ts",
+    "lineNumber": 11,
     "nodeModules": [],
     "prefixes": [
       {
@@ -242,6 +267,7 @@ exports[`markdown should match snapshots: prepend-multiple 1`] = `
         "id": "prepend-multiple-7",
       },
     ],
+    "prefixesLength": 0,
     "replacementId": undefined,
     "sectionHeader": null,
     "sourceFile": "prepend-multiple.md",
diff --git a/src/test/asciidoc.test.ts b/src/test/asciidoc.test.ts
index 68f742f..4826881 100644
--- a/src/test/asciidoc.test.ts
+++ b/src/test/asciidoc.test.ts
@@ -92,7 +92,8 @@ describe('extractSamples', () => {
       {
         ...baseExtract,
         language: 'ts',
-        descriptor: './source.asciidoc:4',
+        descriptor: './source.asciidoc:5',
+        lineNumber: 7,
         id: 'greet-ts',
         content: dedent`
           function greet(who: string) {
@@ -108,8 +109,9 @@ describe('extractSamples', () => {
       {
         ...baseExtract,
         language: 'ts',
-        descriptor: './source.asciidoc:10',
+        descriptor: './source.asciidoc:11',
         id: 'noid-10',
+        lineNumber: 10,
         content: `console.log('Hello', 'TS');`,
       },
     ]);
@@ -147,7 +149,8 @@ describe('extractSamples', () => {
       {
         ...baseExtract,
         language: 'ts',
-        descriptor: './source.asciidoc:2',
+        descriptor: './source.asciidoc:3',
+        lineNumber: 5,
         id: 'implicit-any',
         content: `const x: number = null;`,
         tsOptions: {
@@ -158,7 +161,8 @@ describe('extractSamples', () => {
       {
         ...baseExtract,
         language: 'ts',
-        descriptor: './source.asciidoc:10',
+        descriptor: './source.asciidoc:11',
+        lineNumber: 13,
         id: 'strict',
         content: `const x: number = null;`,
         tsOptions: {
@@ -192,7 +196,8 @@ describe('extractSamples', () => {
       {
         ...baseExtract,
         language: 'ts',
-        descriptor: './source.asciidoc:27',
+        descriptor: './source.asciidoc:28',
+        lineNumber: 30,
         id: 'prepended-with-ids',
         content: `console.log(a);`,
         prefixes: [{id: 'prefix'}, {id: 'combined'}],
@@ -223,7 +228,8 @@ describe('extractSamples', () => {
       {
         ...baseExtract,
         language: 'ts',
-        descriptor: './source.asciidoc:10',
+        descriptor: './source.asciidoc:11',
+        lineNumber: 10,
         id: 'prepend-subset-of-id-10',
         content: `function foo(abc: ABC) {}`,
         prefixes: [{id: 'type-and-func', lines: [1, 1]}],
@@ -249,7 +255,8 @@ describe('extractSamples', () => {
       {
         ...baseExtract,
         language: 'ts',
-        descriptor: './source.asciidoc:1',
+        descriptor: './source.asciidoc:2',
+        lineNumber: 4,
         id: 'tsx-example',
         content: `console.log(a);`,
         isTSX: true,
@@ -287,7 +294,8 @@ describe('extractSamples', () => {
       {
         ...baseExtract,
         language: 'ts',
-        descriptor: './source.asciidoc:13',
+        descriptor: './source.asciidoc:14',
+        lineNumber: 16,
         id: 'back-in-business',
         content: `const backInBusiness = 23;`,
       },
@@ -318,7 +326,8 @@ describe('extractSamples', () => {
     ).toEqual([
       {
         ...baseExtract,
-        descriptor: './source.asciidoc:4',
+        descriptor: './source.asciidoc:5',
+        lineNumber: 4,
         id: 'header-reset-4',
         sectionHeader: 'Chapter 1',
         language: 'ts',
@@ -326,8 +335,9 @@ describe('extractSamples', () => {
       },
       {
         ...baseExtract,
-        descriptor: './source.asciidoc:11',
+        descriptor: './source.asciidoc:12',
         id: 'header-reset-11',
+        lineNumber: 11,
         sectionHeader: 'Chapter 2',
         language: 'ts',
         content: `const x = 12;`,
@@ -356,7 +366,8 @@ describe('extractSamples', () => {
     ).toEqual([
       {
         ...baseExtract,
-        descriptor: './source.asciidoc:4',
+        descriptor: './source.asciidoc:5',
+        lineNumber: 4,
         id: 'header-reset-4',
         sectionHeader: 'Chapter 1',
         language: 'ts',
@@ -364,8 +375,9 @@ describe('extractSamples', () => {
       },
       {
         ...baseExtract,
-        descriptor: './source.asciidoc:10',
+        descriptor: './source.asciidoc:11',
         id: 'header-reset-10',
+        lineNumber: 10,
         sectionHeader: 'Chapter 1',
         language: 'ts',
         content: `const x = 12;`,
@@ -399,7 +411,8 @@ describe('extractSamples', () => {
     ).toEqual([
       {
         ...baseExtract,
-        descriptor: './source.asciidoc:10',
+        descriptor: './source.asciidoc:11',
+        lineNumber: 10,
         id: 'header-reset-done-with-file-10',
         sectionHeader: 'Chapter 2',
         language: 'ts',
diff --git a/src/test/code-sample.test.ts b/src/test/code-sample.test.ts
index 6293912..68eac86 100644
--- a/src/test/code-sample.test.ts
+++ b/src/test/code-sample.test.ts
@@ -51,14 +51,17 @@ describe('code-sample', () => {
     expect(applyPrefixes(extractSamples(ASCIIDOC_PREPEND, 'prepend', 'source.asciidoc'))).toEqual([
       {
         ...baseSample,
-        descriptor: './source.asciidoc:3',
+        descriptor: './source.asciidoc:4',
+        lineNumber: 6,
         language: 'ts',
         id: 'prefix',
         content: `type AB = 'a' | 'b';`,
       },
       {
         ...baseSample,
-        descriptor: './source.asciidoc:9',
+        descriptor: './source.asciidoc:10',
+        lineNumber: 12,
+        prefixesLength: 1,
         language: 'ts',
         id: 'combined',
         content: dedent`
@@ -67,7 +70,8 @@ describe('code-sample', () => {
       },
       {
         ...baseSample,
-        descriptor: './source.asciidoc:17',
+        descriptor: './source.asciidoc:18',
+        lineNumber: 20,
         language: 'ts',
         id: 'final',
         content: `const a: AB = 'a';`,
@@ -81,23 +85,28 @@ describe('code-sample', () => {
     ).toEqual([
       {
         ...baseSample,
-        descriptor: './source.asciidoc:4',
+        descriptor: './source.asciidoc:5',
+        lineNumber: 4,
         language: 'ts',
         id: 'mpd-4',
         content: `type AB = 'a' | 'b';`,
       },
       {
         ...baseSample,
-        descriptor: './source.asciidoc:10',
+        descriptor: './source.asciidoc:11',
+        lineNumber: 10,
         language: 'ts',
         id: 'mpd-10',
+        prefixesLength: 1,
         content: dedent`
           type AB = 'a' | 'b';
           type ABC = AB | 'c';`,
       },
       {
         ...baseSample,
-        descriptor: './source.asciidoc:15',
+        descriptor: './source.asciidoc:16',
+        lineNumber: 15,
+        prefixesLength: 2,
         language: 'ts',
         id: 'mpd-15',
         content: dedent`
@@ -136,7 +145,8 @@ describe('code-sample', () => {
     ).toEqual([
       {
         ...baseSample,
-        descriptor: './source.asciidoc:1',
+        descriptor: './source.asciidoc:2',
+        lineNumber: 4,
         language: 'ts',
         id: 'a',
         content: dedent`
@@ -147,9 +157,11 @@ describe('code-sample', () => {
       },
       {
         ...baseSample,
-        descriptor: './source.asciidoc:10',
+        descriptor: './source.asciidoc:11',
+        lineNumber: 13,
         language: 'ts',
         id: 'b',
+        prefixesLength: 3,
         content: dedent`
         interface Person {
           name: string;
@@ -186,7 +198,8 @@ describe('code-sample', () => {
     ).toEqual([
       {
         ...baseSample,
-        descriptor: './source.asciidoc:1',
+        descriptor: './source.asciidoc:2',
+        lineNumber: 4,
         language: 'js',
         id: 'a',
         content: dedent`
@@ -196,9 +209,11 @@ describe('code-sample', () => {
       },
       {
         ...baseSample,
-        descriptor: './source.asciidoc:9',
+        descriptor: './source.asciidoc:10',
+        lineNumber: 12,
         language: 'ts',
         id: 'b',
+        prefixesLength: 2,
         content: dedent`
         import _ from 'lodash';
         const p = {name: 'Bob'};
@@ -232,14 +247,16 @@ describe('code-sample', () => {
     ).toEqual([
       {
         ...baseSample,
-        descriptor: './source.asciidoc:1',
+        descriptor: './source.asciidoc:2',
+        lineNumber: 4,
         language: 'ts',
         id: 'hello',
         content: `console.log('Hello');`,
       },
       {
         ...baseSample,
-        descriptor: './source.asciidoc:8',
+        descriptor: './source.asciidoc:9',
+        lineNumber: 10,
         language: null,
         id: 'hello-output',
         content: 'Hello',
@@ -322,6 +339,8 @@ describe('applyReplacements', () => {
     descriptor: './source.asciidoc:4',
     id: 'foo',
     content: '',
+    prefixesLength: 0,
+    lineNumber: 4,
   };
 
   const compressed = dedent`
diff --git a/src/test/common.ts b/src/test/common.ts
index 76a6235..99f2e70 100644
--- a/src/test/common.ts
+++ b/src/test/common.ts
@@ -7,6 +7,7 @@ export const baseSample = {
   checkJS: false,
   sectionHeader: null,
   sourceFile: 'source.asciidoc',
+  prefixesLength: 0,
 };
 
 export const baseExtract = {