From d0799729fa97202c9463954fb1d889aa60eb9f56 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 14 May 2026 08:27:58 -0500 Subject: [PATCH] fix: handle no output or setup better --- packages/language-service/package.json | 9 +- .../language-service/src/language-plugin.ts | 93 +++++++------- .../language-service/src/service-plugin.ts | 119 +++++++++--------- .../tests/language-plugin.spec.ts | 79 ++++++++++++ packages/language-service/tsconfig.build.json | 9 ++ packages/language-service/tsconfig.json | 13 +- packages/language-service/tsconfig.spec.json | 8 ++ pnpm-lock.yaml | 3 + 8 files changed, 222 insertions(+), 111 deletions(-) create mode 100644 packages/language-service/tests/language-plugin.spec.ts create mode 100644 packages/language-service/tsconfig.build.json create mode 100644 packages/language-service/tsconfig.spec.json diff --git a/packages/language-service/package.json b/packages/language-service/package.json index 04ada26..e582163 100644 --- a/packages/language-service/package.json +++ b/packages/language-service/package.json @@ -36,8 +36,10 @@ "volar" ], "scripts": { - "build": "tsc --build", - "prepack": "tsc --build --clean && tsc --build", + "build": "tsc --build tsconfig.build.json", + "prepack": "tsc --build tsconfig.build.json --clean && tsc --build tsconfig.build.json", + "test:lib": "vitest", + "test:types": "tsc --noEmit --project tsconfig.spec.json", "test:eslint": "eslint ." }, "dependencies": { @@ -50,7 +52,8 @@ "devDependencies": { "@tempblot/config": "workspace:*", "@types/node": "^24.5.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.6" }, "sideEffects": false } diff --git a/packages/language-service/src/language-plugin.ts b/packages/language-service/src/language-plugin.ts index 8ca07d1..c8bd09f 100644 --- a/packages/language-service/src/language-plugin.ts +++ b/packages/language-service/src/language-plugin.ts @@ -130,25 +130,45 @@ function* getTempblotEmbeddedCodes( ): Generator { const setups = getRootBlocks(rootDocument, "setup"); const outputs = getRootBlocks(rootDocument, "output"); + const setup = setups[0]; + const output = outputs[0]; - // If we have both setup and output, combine them into a single TypeScript context - // This allows setup variables to be accessible in output interpolations - if (setups.length > 0 && outputs.length > 0) { - const setup = setups[0]; // Take the first setup block - const output = outputs[0]; // Take the first output block + // Combine setup and output interpolations into one TypeScript context so setup + // variables are visible from interpolation expressions when both exist. + if (setup || output) { + const base = `export {}; // Make this file a module\n\n`; + let combinedText = base; + const tsMappings: CodeMapping[] = []; - const setupText = snapshot.getText(setup.startTagEnd, setup.endTagStart); - const outputText = snapshot.getText(output.startTagEnd, output.endTagStart); + if (setup) { + const setupText = snapshot.getText(setup.startTagEnd, setup.endTagStart); + const setupGeneratedOffset = combinedText.length; + combinedText += setupText; - // Extract interpolation expressions and their positions from output - const interpolationsData = scanInterpolations(outputText); + tsMappings.push({ + sourceOffsets: [setup.startTagEnd], + generatedOffsets: [setupGeneratedOffset], + lengths: [setupText.length], + data: { + completion: true, + format: true, + navigation: true, + semantic: true, + structure: true, + verification: true, + }, + }); + } - // Create a combined TypeScript context wrapped in a module - // This ensures each .blot file has its own isolated scope - const base = `export {}; // Make this file a module\n\n`; - let combinedText = `${base}${setupText}\n\n// Output interpolations:\n`; + const outputText = output + ? snapshot.getText(output.startTagEnd, output.endTagStart) + : ""; + + const interpolationsData = output ? scanInterpolations(outputText) : []; + if (interpolationsData.length > 0) { + combinedText += `\n\n// Output interpolations:\n`; + } - const tsInterpolationMappings: CodeMapping[] = []; interpolationsData.forEach((interp) => { const interpLine = `(${interp.expression});\n`; const interpStartOffset = combinedText.length; @@ -156,7 +176,7 @@ function* getTempblotEmbeddedCodes( // Map the interpolation expression to the original source const expressionStart = interpStartOffset + `(`.length; - tsInterpolationMappings.push({ + tsMappings.push({ sourceOffsets: [output.startTagEnd + interp.sourceStart], generatedOffsets: [expressionStart], lengths: [interp.expression.length], @@ -171,34 +191,23 @@ function* getTempblotEmbeddedCodes( }); }); - yield { - id: "combined_context", - languageId: "typescript", - snapshot: { - getText: (start, end) => combinedText.substring(start, end), - getLength: () => combinedText.length, - getChangeRange: () => undefined, - }, - mappings: [ - // Mapping for setup block - { - sourceOffsets: [setup.startTagEnd], - generatedOffsets: [base.length], - lengths: [setupText.length], - data: { - completion: true, - format: true, - navigation: true, - semantic: true, - structure: true, - verification: true, - }, + if (tsMappings.length > 0) { + yield { + id: "combined_context", + languageId: "typescript", + snapshot: { + getText: (start, end) => combinedText.substring(start, end), + getLength: () => combinedText.length, + getChangeRange: () => undefined, }, - // Mappings for interpolation expressions - ...tsInterpolationMappings, - ], - embeddedCodes: [], - }; + mappings: tsMappings, + embeddedCodes: [], + }; + } + + if (!output) { + return; + } // Create JSON output with interpolations replaced by placeholder values const { transformedText, jsonMappings } = createJsonWithMappings( diff --git a/packages/language-service/src/service-plugin.ts b/packages/language-service/src/service-plugin.ts index f70b8f9..d1fb72f 100644 --- a/packages/language-service/src/service-plugin.ts +++ b/packages/language-service/src/service-plugin.ts @@ -4,12 +4,14 @@ import type { LanguageServicePlugin, LanguageServicePluginInstance, } from "@volar/language-service"; +import type { ParsedRoot } from "@tempblot/parser"; import { URI } from "vscode-uri"; import { TempblotVirtualCode } from "./language-plugin.ts"; type DiagnosticsDocument = Parameters< NonNullable >[0]; +type PositionAt = DiagnosticsDocument["positionAt"]; export function createTempblotServicePlugin(): LanguageServicePlugin { return { @@ -35,69 +37,70 @@ export function createTempblotServicePlugin(): LanguageServicePlugin { if (!(virtualCode instanceof TempblotVirtualCode)) { return; } - const setupNodes = virtualCode.rootDocument.blocks.filter( - (root) => root.tag === "setup", - ); - const outputNodes = virtualCode.rootDocument.blocks.filter( - (root) => root.tag === "output", + return getTempblotRootDiagnostics( + virtualCode.rootDocument, + document.positionAt.bind(document), ); + }, + }; + }, + }; +} - if (setupNodes.length == 1 && outputNodes.length == 1) { - return; - } +export function getTempblotRootDiagnostics( + rootDocument: ParsedRoot, + positionAt: PositionAt, +): Diagnostic[] | undefined { + const setupNodes = rootDocument.blocks.filter((root) => root.tag === "setup"); + const outputNodes = rootDocument.blocks.filter( + (root) => root.tag === "output", + ); - const errors: Diagnostic[] = []; + if ( + setupNodes.length <= 1 && + outputNodes.length <= 1 && + setupNodes.length + outputNodes.length > 0 + ) { + return; + } - if (setupNodes.length === 0) { - errors.push({ - severity: 1, - range: { - start: document.positionAt(0), - end: document.positionAt(1), - }, - source: "tempblot", - message: "Missing setup tag.", - }); - } + const errors: Diagnostic[] = []; - if (outputNodes.length === 0) { - errors.push({ - severity: 1, - range: { - start: document.positionAt(0), - end: document.positionAt(1), - }, - source: "tempblot", - message: "Missing output tag.", - }); - } + if (setupNodes.length === 0 && outputNodes.length === 0) { + errors.push({ + severity: 1, + range: { + start: positionAt(0), + end: positionAt(1), + }, + source: "tempblot", + message: "Missing setup or output tag.", + }); + } - for (let i = 1; i < setupNodes.length; i++) { - errors.push({ - severity: 2, - range: { - start: document.positionAt(setupNodes[i].start), - end: document.positionAt(setupNodes[i].end), - }, - source: "tempblot", - message: "Only one setup tag is allowed.", - }); - } + for (let i = 1; i < setupNodes.length; i++) { + errors.push({ + severity: 2, + range: { + start: positionAt(setupNodes[i].start), + end: positionAt(setupNodes[i].end), + }, + source: "tempblot", + message: "Only one setup tag is allowed.", + }); + } - for (let i = 1; i < outputNodes.length; i++) { - errors.push({ - severity: 2, - range: { - start: document.positionAt(outputNodes[i].start), - end: document.positionAt(outputNodes[i].end), - }, - source: "tempblot", - message: "Only one output tag is allowed.", - }); - } - return errors; - }, - }; - }, - }; + for (let i = 1; i < outputNodes.length; i++) { + errors.push({ + severity: 2, + range: { + start: positionAt(outputNodes[i].start), + end: positionAt(outputNodes[i].end), + }, + source: "tempblot", + message: "Only one output tag is allowed.", + }); + } + + return errors; } diff --git a/packages/language-service/tests/language-plugin.spec.ts b/packages/language-service/tests/language-plugin.spec.ts new file mode 100644 index 0000000..fe9a8b7 --- /dev/null +++ b/packages/language-service/tests/language-plugin.spec.ts @@ -0,0 +1,79 @@ +import { expect, test } from "vitest"; +import { parseTempblotRoot } from "@tempblot/parser"; +import type * as ts from "typescript"; +import { TempblotVirtualCode } from "../src/language-plugin.ts"; +import { getTempblotRootDiagnostics } from "../src/service-plugin.ts"; + +function createVirtualCode(source: string): TempblotVirtualCode { + const snapshot = { + getText: (start, end) => source.substring(start, end), + getLength: () => source.length, + getChangeRange: () => undefined, + } satisfies ts.IScriptSnapshot; + + return new TempblotVirtualCode(snapshot); +} + +const positionAt = (offset: number) => ({ line: 0, character: offset }); + +test("creates TypeScript embedded code for setup-only files", () => { + const source = ` +const value: number = 1; +`; + + const virtualCode = createVirtualCode(source); + const combinedContext = virtualCode.embeddedCodes.find( + (code) => code.id === "combined_context", + ); + + expect(combinedContext?.snapshot.getText(0, combinedContext.snapshot.getLength())) + .toContain("const value: number = 1;"); + expect(virtualCode.embeddedCodes.map((code) => code.id)).not.toContain( + "output_json", + ); +}); + +test("creates output embedded code for output-only files", () => { + const source = ` +{"value": <<1 + 1>>} +`; + + const virtualCode = createVirtualCode(source); + const combinedContext = virtualCode.embeddedCodes.find( + (code) => code.id === "combined_context", + ); + const outputJson = virtualCode.embeddedCodes.find( + (code) => code.id === "output_json", + ); + + expect(combinedContext?.snapshot.getText(0, combinedContext.snapshot.getLength())) + .toContain("(1 + 1);"); + expect(outputJson?.snapshot.getText(0, outputJson.snapshot.getLength())) + .toContain('{"value": null}'); +}); + +test("does not report missing-block diagnostics for single-section files", () => { + const setupOnlyDiagnostics = getTempblotRootDiagnostics( + parseTempblotRoot(""), + positionAt, + ); + const outputOnlyDiagnostics = getTempblotRootDiagnostics( + parseTempblotRoot(""), + positionAt, + ); + + expect(setupOnlyDiagnostics).toBeUndefined(); + expect(outputOnlyDiagnostics).toBeUndefined(); +}); + +test("reports diagnostics when neither root section exists", () => { + expect( + getTempblotRootDiagnostics(parseTempblotRoot("plain text"), positionAt), + ).toMatchObject([ + { + severity: 1, + source: "tempblot", + message: "Missing setup or output tag.", + }, + ]); +}); diff --git a/packages/language-service/tsconfig.build.json b/packages/language-service/tsconfig.build.json new file mode 100644 index 0000000..8da3e7d --- /dev/null +++ b/packages/language-service/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "@tempblot/config/tsconfig.json", + "include": ["src"], + "references": [{ "path": "../parser/tsconfig.build.json" }], + "compilerOptions": { + "declaration": true, + "outDir": "lib" + } +} diff --git a/packages/language-service/tsconfig.json b/packages/language-service/tsconfig.json index 0072f8d..dcb77f1 100644 --- a/packages/language-service/tsconfig.json +++ b/packages/language-service/tsconfig.json @@ -1,10 +1,7 @@ { - "extends": "@tempblot/config/tsconfig.json", - "include": ["src"], - "references": [{ "path": "../parser/tsconfig.build.json" }], - - "compilerOptions": { - "declaration": true, - "outDir": "lib" - } + "files": [], + "references": [ + { "path": "./tsconfig.build.json" }, + { "path": "./tsconfig.spec.json" } + ] } diff --git a/packages/language-service/tsconfig.spec.json b/packages/language-service/tsconfig.spec.json new file mode 100644 index 0000000..a7fc895 --- /dev/null +++ b/packages/language-service/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "@tempblot/config/tsconfig.json", + "include": ["src", "tests"], + "references": [{ "path": "../parser/tsconfig.build.json" }], + "compilerOptions": { + "noEmit": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4770f42..7c7dd03 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -217,6 +217,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.6 + version: 4.1.6(@types/node@24.12.4)(vite@8.0.12(@types/node@24.12.4)(esbuild@0.28.0)(jiti@2.7.0)(yaml@2.9.0)) packages/parser: devDependencies: -- 2.51.2