From 8e8923272f9539511ffa047bbe785f74de80dce0 Mon Sep 17 00:00:00 2001 From: Corbin Crutchley Date: Thu, 14 May 2026 08:52:40 -0500 Subject: [PATCH] feat: handle _paths.blot files in LSP --- .../language-service/src/language-plugin.ts | 173 +++++++++--------- .../language-service/src/service-plugin.ts | 49 ++++- .../tests/language-plugin.spec.ts | 79 +++++++- 3 files changed, 202 insertions(+), 99 deletions(-) diff --git a/packages/language-service/src/language-plugin.ts b/packages/language-service/src/language-plugin.ts index 5ed4856..92b43cd 100644 --- a/packages/language-service/src/language-plugin.ts +++ b/packages/language-service/src/language-plugin.ts @@ -27,12 +27,12 @@ export function createTempblotLanguagePlugin( } }, createVirtualCode( - _uri: URI, + uri: URI, languageId: string, snapshot: ts.IScriptSnapshot, ) { if (languageId === "tempblot") { - return new TempblotVirtualCode(snapshot); + return new TempblotVirtualCode(snapshot, isPathBlotUri(uri)); } }, typescript: { @@ -87,6 +87,11 @@ function getCombinedContextCode(root: VirtualCode) { return root.embeddedCodes?.find((code) => code.id === "combined_context"); } +export function isPathBlotUri(uri: URI | string): boolean { + const path = typeof uri === "string" ? uri : uri.path; + return path.endsWith("/_paths.blot") || path.endsWith("\\_paths.blot"); +} + export class TempblotVirtualCode implements VirtualCode { id = "root"; languageId = "tempblot"; @@ -97,9 +102,11 @@ export class TempblotVirtualCode implements VirtualCode { rootDocument: ParsedRoot; snapshot: ts.IScriptSnapshot; + isPathFile: boolean; - constructor(snapshot: ts.IScriptSnapshot) { + constructor(snapshot: ts.IScriptSnapshot, isPathFile = false) { this.snapshot = snapshot; + this.isPathFile = isPathFile; this.mappings = [ { sourceOffsets: [0], @@ -135,98 +142,96 @@ function* getTempblotEmbeddedCodes( // 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 base = `export {}; // Make this file a module\n\n`; + let combinedText = base; + const tsMappings: CodeMapping[] = []; - if (setup) { - const setupText = snapshot.getText(setup.startTagEnd, setup.endTagStart); - const setupGeneratedOffset = combinedText.length; - combinedText += setupText; + if (setup) { + const setupText = snapshot.getText(setup.startTagEnd, setup.endTagStart); + const setupGeneratedOffset = combinedText.length; + combinedText += setupText; - tsMappings.push({ - sourceOffsets: [setup.startTagEnd], - generatedOffsets: [setupGeneratedOffset], - lengths: [setupText.length], - data: { - completion: true, - format: true, - navigation: true, - semantic: true, - structure: true, - verification: true, - }, - }); - } + tsMappings.push({ + sourceOffsets: [setup.startTagEnd], + generatedOffsets: [setupGeneratedOffset], + lengths: [setupText.length], + data: { + completion: true, + format: true, + navigation: true, + semantic: true, + structure: true, + verification: true, + }, + }); + } - const outputText = output - ? snapshot.getText(output.startTagEnd, output.endTagStart) - : ""; + 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 interpolationsData = output ? scanInterpolations(outputText) : []; + if (interpolationsData.length > 0) { + combinedText += `\n\n// Output interpolations:\n`; + } - interpolationsData.forEach((interp) => { - const interpLine = `(${interp.expression});\n`; - const interpStartOffset = combinedText.length; - combinedText += interpLine; + interpolationsData.forEach((interp) => { + const interpLine = `(${interp.expression});\n`; + const interpStartOffset = combinedText.length; + combinedText += interpLine; - // Map the interpolation expression to the original source - const expressionStart = interpStartOffset + `(`.length; - tsMappings.push({ - sourceOffsets: [output.startTagEnd + interp.sourceStart], - generatedOffsets: [expressionStart], - lengths: [interp.expression.length], - data: { - completion: true, - format: true, - navigation: true, - semantic: true, - structure: true, - verification: true, - }, - }); + // Map the interpolation expression to the original source + const expressionStart = interpStartOffset + `(`.length; + tsMappings.push({ + sourceOffsets: [output.startTagEnd + interp.sourceStart], + generatedOffsets: [expressionStart], + lengths: [interp.expression.length], + data: { + completion: true, + format: true, + navigation: true, + semantic: true, + structure: true, + verification: true, + }, }); + }); - yield { - id: "combined_context", - languageId: "typescript", - snapshot: { - getText: (start, end) => combinedText.substring(start, end), - getLength: () => combinedText.length, - getChangeRange: () => undefined, - }, - mappings: tsMappings, - embeddedCodes: [], - }; + yield { + id: "combined_context", + languageId: "typescript", + snapshot: { + getText: (start, end) => combinedText.substring(start, end), + getLength: () => combinedText.length, + getChangeRange: () => undefined, + }, + mappings: tsMappings, + embeddedCodes: [], + }; - if (!output) { - return; - } + if (!output) { + return; + } - // Create JSON output with interpolations replaced by placeholder values - const { transformedText, jsonMappings } = createJsonWithMappings( - outputText, - interpolationsData, - output.startTagEnd, - ); + // Create JSON output with interpolations replaced by placeholder values + const { transformedText, jsonMappings } = createJsonWithMappings( + outputText, + interpolationsData, + output.startTagEnd, + ); - // TODO: Make generic and not tied to JSON - yield { - id: "output_json", - languageId: "json", - snapshot: { - getText: (start, end) => transformedText.substring(start, end), - getLength: () => transformedText.length, - getChangeRange: () => undefined, - }, - mappings: jsonMappings, - embeddedCodes: [], - }; - } + // TODO: Make generic and not tied to JSON + yield { + id: "output_json", + languageId: "json", + snapshot: { + getText: (start, end) => transformedText.substring(start, end), + getLength: () => transformedText.length, + getChangeRange: () => undefined, + }, + mappings: jsonMappings, + embeddedCodes: [], + }; } function createJsonWithMappings( diff --git a/packages/language-service/src/service-plugin.ts b/packages/language-service/src/service-plugin.ts index d1fb72f..fc3018e 100644 --- a/packages/language-service/src/service-plugin.ts +++ b/packages/language-service/src/service-plugin.ts @@ -39,6 +39,7 @@ export function createTempblotServicePlugin(): LanguageServicePlugin { } return getTempblotRootDiagnostics( virtualCode.rootDocument, + virtualCode.isPathFile, document.positionAt.bind(document), ); }, @@ -49,6 +50,7 @@ export function createTempblotServicePlugin(): LanguageServicePlugin { export function getTempblotRootDiagnostics( rootDocument: ParsedRoot, + isPathFile: boolean, positionAt: PositionAt, ): Diagnostic[] | undefined { const setupNodes = rootDocument.blocks.filter((root) => root.tag === "setup"); @@ -56,17 +58,38 @@ export function getTempblotRootDiagnostics( (root) => root.tag === "output", ); + const hasValidSetupCount = setupNodes.length <= 1; + const hasValidOutputCount = outputNodes.length === 1; + const hasValidPathOutputCount = outputNodes.length === 0; + if ( - setupNodes.length <= 1 && - outputNodes.length <= 1 && - setupNodes.length + outputNodes.length > 0 + isPathFile && + hasValidSetupCount && + setupNodes.length === 1 && + hasValidPathOutputCount ) { return; } + if (!isPathFile && hasValidSetupCount && hasValidOutputCount) { + return; + } + const errors: Diagnostic[] = []; - if (setupNodes.length === 0 && outputNodes.length === 0) { + if (isPathFile && setupNodes.length === 0) { + errors.push({ + severity: 1, + range: { + start: positionAt(0), + end: positionAt(1), + }, + source: "tempblot", + message: "Missing setup tag.", + }); + } + + if (!isPathFile && outputNodes.length === 0) { errors.push({ severity: 1, range: { @@ -74,7 +97,7 @@ export function getTempblotRootDiagnostics( end: positionAt(1), }, source: "tempblot", - message: "Missing setup or output tag.", + message: "Missing output tag.", }); } @@ -90,6 +113,22 @@ export function getTempblotRootDiagnostics( }); } + if (isPathFile) { + for (const outputNode of outputNodes) { + errors.push({ + severity: 1, + range: { + start: positionAt(outputNode.start), + end: positionAt(outputNode.end), + }, + source: "tempblot", + message: "Output tag is not allowed in path files.", + }); + } + + return errors; + } + for (let i = 1; i < outputNodes.length; i++) { errors.push({ severity: 2, diff --git a/packages/language-service/tests/language-plugin.spec.ts b/packages/language-service/tests/language-plugin.spec.ts index 1730ace..08d7192 100644 --- a/packages/language-service/tests/language-plugin.spec.ts +++ b/packages/language-service/tests/language-plugin.spec.ts @@ -1,27 +1,36 @@ import { expect, test } from "vitest"; import { parseTempblotRoot } from "@tempblot/parser"; import type * as ts from "typescript"; -import { TempblotVirtualCode } from "../src/language-plugin.ts"; +import { isPathBlotUri, TempblotVirtualCode } from "../src/language-plugin.ts"; import { getTempblotRootDiagnostics } from "../src/service-plugin.ts"; -function createVirtualCode(source: string): TempblotVirtualCode { +function createVirtualCode( + source: string, + isPathFile = false, +): TempblotVirtualCode { const snapshot = { getText: (start, end) => source.substring(start, end), getLength: () => source.length, getChangeRange: () => undefined, } satisfies ts.IScriptSnapshot; - return new TempblotVirtualCode(snapshot); + return new TempblotVirtualCode(snapshot, isPathFile); } const positionAt = (offset: number) => ({ line: 0, character: offset }); -test("creates TypeScript embedded code for setup-only files", () => { +test("detects path files from URI and TypeScript plugin file names", () => { + expect(isPathBlotUri("/project/_paths.blot")).toBe(true); + expect(isPathBlotUri("C:\\project\\_paths.blot")).toBe(true); + expect(isPathBlotUri("/project/_path.blot")).toBe(false); +}); + +test("creates TypeScript embedded code for setup-only path files", () => { const source = ` const value: number = 1; `; - const virtualCode = createVirtualCode(source); + const virtualCode = createVirtualCode(source, true); const combinedContext = virtualCode.embeddedCodes.find( (code) => code.id === "combined_context", ); @@ -74,28 +83,78 @@ test("creates empty TypeScript embedded code for output-only files without inter .toContain('"test": "a"'); }); -test("does not report missing-block diagnostics for single-section files", () => { +test("creates empty TypeScript embedded code for files without root sections", () => { + const virtualCode = createVirtualCode("plain text"); + const combinedContext = virtualCode.embeddedCodes.find( + (code) => code.id === "combined_context", + ); + + expect(combinedContext?.snapshot.getText(0, combinedContext.snapshot.getLength())) + .toBe("export {}; // Make this file a module\n\n"); + expect(combinedContext?.mappings).toEqual([]); +}); + +test("requires output for regular files", () => { + expect( + getTempblotRootDiagnostics( + parseTempblotRoot(""), + false, + positionAt, + ), + ).toMatchObject([ + { + severity: 1, + source: "tempblot", + message: "Missing output tag.", + }, + ]); +}); + +test("allows output-only regular files", () => { + const outputOnlyDiagnostics = getTempblotRootDiagnostics( + parseTempblotRoot(""), + false, + positionAt, + ); + + expect(outputOnlyDiagnostics).toBeUndefined(); +}); + +test("requires setup and rejects output for path files", () => { const setupOnlyDiagnostics = getTempblotRootDiagnostics( parseTempblotRoot(""), + true, positionAt, ); const outputOnlyDiagnostics = getTempblotRootDiagnostics( parseTempblotRoot(""), + true, positionAt, ); expect(setupOnlyDiagnostics).toBeUndefined(); - expect(outputOnlyDiagnostics).toBeUndefined(); + expect(outputOnlyDiagnostics).toMatchObject([ + { + severity: 1, + source: "tempblot", + message: "Missing setup tag.", + }, + { + severity: 1, + source: "tempblot", + message: "Output tag is not allowed in path files.", + }, + ]); }); -test("reports diagnostics when neither root section exists", () => { +test("reports diagnostics when regular files have no output", () => { expect( - getTempblotRootDiagnostics(parseTempblotRoot("plain text"), positionAt), + getTempblotRootDiagnostics(parseTempblotRoot("plain text"), false, positionAt), ).toMatchObject([ { severity: 1, source: "tempblot", - message: "Missing setup or output tag.", + message: "Missing output tag.", }, ]); }); -- 2.51.2