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.",
},
]);
});