Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,6 @@ export {
normaliseFieldDescriptorMap,
normaliseShortcodeSchema,
normaliseSchema,
typeIncludes,
formatType,
} from "./schema.js";
31 changes: 29 additions & 2 deletions packages/core/src/types/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ export interface DeprecatedSpec {
* Describes the type, constraints, and metadata for a configuration option.
*/
export interface FieldDescriptor {
/** Data type of the field (e.g., "string", "number", "boolean", "object", "array", "content"). */
type?: string;
/** Data type of the field. A single type name or an array of type names for union types. */
type?: string | string[];
/** Whether the field is required. */
required?: boolean;
/** Default value for the field. */
Expand Down Expand Up @@ -273,3 +273,30 @@ export function normaliseSchema(raw: RawSchema): ExtensionSchema {

return result;
}

/**
* Check whether a type spec includes a given type name.
*
* @param typeSpec - Type spec (string, string array, or undefined).
* @param name - Type name to look for.
* @returns True if the type spec includes the given name.
*/
export function typeIncludes(typeSpec: string | string[] | undefined, name: string): boolean {
if (Array.isArray(typeSpec)) {
return typeSpec.includes(name);
}
return typeSpec === name;
}

/**
* Format a type spec for display (e.g., "number | boolean").
*
* @param typeSpec - Type spec (string, string array, or undefined).
* @returns Human-readable type string.
*/
export function formatType(typeSpec: string | string[] | undefined): string {
if (Array.isArray(typeSpec)) {
return typeSpec.join(" | ");
}
return typeSpec ?? "";
}
63 changes: 63 additions & 0 deletions packages/core/tests/filesystem/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import {
normaliseFieldDescriptorMap,
normaliseShortcodeSchema,
normaliseSchema,
typeIncludes,
formatType,
} from "../../src/types/schema.js";
import { findSchemaFile, parseSchemaContent, parseSchemaFile, readSchema } from "../../src/filesystem/schema.js";
import { SchemaCache } from "../../src/filesystem/schema-cache.js";
Expand Down Expand Up @@ -130,6 +132,16 @@ describe("normaliseFieldDescriptor", () => {
expect(result.completion!.type).toBe("file");
expect(result.completion!.extensions).toEqual([".lua"]);
});

it("preserves type when it is an array of strings", () => {
const result = normaliseFieldDescriptor({
type: ["number", "boolean"],
default: 100,
});

expect(result.type).toEqual(["number", "boolean"]);
expect(result.default).toBe(100);
});
});

describe("normaliseFieldDescriptorMap", () => {
Expand Down Expand Up @@ -478,6 +490,57 @@ options:
expect(schema.options!["config"].properties!["name"].required).toBe(true);
expect(schema.options!["config"].properties!["nested"].properties!["level"].type).toBe("number");
});

it("round-trips union type from YAML", () => {
const yamlContent = `
options:
fadeInAndOut:
type: [number, boolean]
default: 100
description: "Duration in ms. Set to false to disable."
`;

const schema = parseSchemaContent(yamlContent);

expect(schema.options!["fadeInAndOut"].type).toEqual(["number", "boolean"]);
expect(schema.options!["fadeInAndOut"].default).toBe(100);
});
});

describe("typeIncludes", () => {
it("returns true for matching single string", () => {
expect(typeIncludes("boolean", "boolean")).toBe(true);
});

it("returns false for non-matching single string", () => {
expect(typeIncludes("string", "boolean")).toBe(false);
});

it("returns true when array contains the type", () => {
expect(typeIncludes(["number", "boolean"], "boolean")).toBe(true);
});

it("returns false when array does not contain the type", () => {
expect(typeIncludes(["number", "boolean"], "string")).toBe(false);
});

it("returns false for undefined", () => {
expect(typeIncludes(undefined, "boolean")).toBe(false);
});
});

describe("formatType", () => {
it("returns single type name unchanged", () => {
expect(formatType("number")).toBe("number");
});

it("joins array types with pipe separator", () => {
expect(formatType(["number", "boolean"])).toBe("number | boolean");
});

it("returns empty string for undefined", () => {
expect(formatType(undefined)).toBe("");
});
});

describe("filesystem schema functions", () => {
Expand Down
4 changes: 2 additions & 2 deletions src/providers/elementAttributeCompletionProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import type { FieldDescriptor, SchemaCache, ExtensionSchema } from "@quarto-wizard/core";
import { discoverInstalledExtensions } from "@quarto-wizard/core";
import { discoverInstalledExtensions, typeIncludes } from "@quarto-wizard/core";
import { parseAttributeAtPosition, type PandocElementType } from "../utils/elementAttributeParser";
import { getWordAtOffset, hasCompletableValues, buildAttributeDoc } from "../utils/schemaDocumentation";
import { isFilePathDescriptor, buildFilePathCompletions } from "../utils/filePathCompletion";
Expand Down Expand Up @@ -353,7 +353,7 @@ export class ElementAttributeCompletionProvider implements vscode.CompletionItem
}
}

if (descriptor.type === "boolean" && items.length === 0) {
if (typeIncludes(descriptor.type, "boolean") && items.length === 0) {
for (const label of ["true", "false"]) {
const item = new vscode.CompletionItem(label, vscode.CompletionItemKind.Value);
item.detail = source;
Expand Down
56 changes: 28 additions & 28 deletions src/providers/inlineAttributeDiagnosticsProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as vscode from "vscode";
import type { SchemaCache, FieldDescriptor, DeprecatedSpec, ShortcodeSchema } from "@quarto-wizard/core";
import { typeIncludes, formatType } from "@quarto-wizard/core";
import { parseAttributeAtPosition } from "../utils/elementAttributeParser";
import { parseShortcodeAtPosition } from "../utils/shortcodeParser";
import {
Expand Down Expand Up @@ -429,39 +430,38 @@ export function validateInlineValue(

// Type check (coerce from string).
if (descriptor.type) {
switch (descriptor.type) {
case "number": {
const num = Number(value);
if (!Number.isFinite(num)) {
findings.push({
message: `Attribute "${key}": expected type "number", got string "${value}".`,
severity: "error",
code: "schema-type-mismatch",
});
return findings;
}
break;
// Early exit when the type has no inline-representable members.
const hasInlineType =
typeIncludes(descriptor.type, "string") ||
typeIncludes(descriptor.type, "number") ||
typeIncludes(descriptor.type, "boolean");
if (!hasInlineType) {
return findings;
}

// If "string" is in the union, any string value is valid.
if (!typeIncludes(descriptor.type, "string")) {
let typeValid = false;

if (typeIncludes(descriptor.type, "number") && Number.isFinite(Number(value))) {
typeValid = true;
}
case "boolean": {

if (!typeValid && typeIncludes(descriptor.type, "boolean")) {
const lower = value.toLowerCase();
if (lower !== "true" && lower !== "false") {
findings.push({
message: `Attribute "${key}": expected type "boolean" ("true" or "false"), got string "${value}".`,
severity: "error",
code: "schema-type-mismatch",
});
return findings;
if (lower === "true" || lower === "false") {
typeValid = true;
}
break;
}
case "string":
// Always valid.
break;
case "array":
case "object":
case "content":
// Not representable inline; skip all further checks.

if (!typeValid) {
findings.push({
message: `Attribute "${key}": expected type "${formatType(descriptor.type)}", got string "${value}".`,
severity: "error",
code: "schema-type-mismatch",
});
return findings;
}
}
}

Expand Down
4 changes: 2 additions & 2 deletions src/providers/shortcodeCompletionProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import type { ShortcodeSchema, FieldDescriptor, SchemaCache } from "@quarto-wizard/core";
import { discoverInstalledExtensions } from "@quarto-wizard/core";
import { discoverInstalledExtensions, typeIncludes } from "@quarto-wizard/core";
import { parseShortcodeAtPosition } from "../utils/shortcodeParser";
import { getWordAtOffset, hasCompletableValues, buildAttributeDoc } from "../utils/schemaDocumentation";
import { isFilePathDescriptor, buildFilePathCompletions } from "../utils/filePathCompletion";
Expand Down Expand Up @@ -323,7 +323,7 @@ export class ShortcodeCompletionProvider implements vscode.CompletionItemProvide
items.push(...fileItems);
}

if (descriptor.type === "boolean" && items.length === 0) {
if (typeIncludes(descriptor.type, "boolean") && items.length === 0) {
const trueItem = new vscode.CompletionItem("true", vscode.CompletionItemKind.Value);
trueItem.sortText = "!1_true";
items.push(trueItem);
Expand Down
14 changes: 10 additions & 4 deletions src/providers/yamlCompletionProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import * as vscode from "vscode";
import { discoverInstalledExtensions, formatExtensionId, getExtensionTypes } from "@quarto-wizard/core";
import {
discoverInstalledExtensions,
formatExtensionId,
getExtensionTypes,
typeIncludes,
formatType,
} from "@quarto-wizard/core";
import type { SchemaCache, ExtensionSchema, FieldDescriptor, InstalledExtension } from "@quarto-wizard/core";
import { getYamlKeyPath, getYamlIndentLevel, isInYamlRegion, getExistingKeysAtPath } from "../utils/yamlPosition";
import { isFilePathDescriptor, buildFilePathCompletions } from "../utils/filePathCompletion";
Expand Down Expand Up @@ -315,7 +321,7 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {
}
}

if (descriptor.type === "boolean") {
if (typeIncludes(descriptor.type, "boolean")) {
for (const label of ["true", "false"]) {
const item = new vscode.CompletionItem(label, vscode.CompletionItemKind.Value);
item.insertText = ` ${label}`;
Expand All @@ -337,7 +343,7 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {
}

private fieldToCompletionItem(key: string, descriptor: FieldDescriptor): vscode.CompletionItem {
const isObject = descriptor.type === "object" || descriptor.properties !== undefined;
const isObject = typeIncludes(descriptor.type, "object") || descriptor.properties !== undefined;
const kind = isObject ? vscode.CompletionItemKind.Module : vscode.CompletionItemKind.Property;
const item = new vscode.CompletionItem(key, kind);

Expand All @@ -349,7 +355,7 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {

const meta: string[] = [];
if (descriptor.type) {
meta.push(`**Type:** \`${descriptor.type}\``);
meta.push(`**Type:** \`${formatType(descriptor.type)}\``);
}
if (descriptor.required) {
meta.push("**Required**");
Expand Down
20 changes: 16 additions & 4 deletions src/providers/yamlDiagnosticsProvider.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import * as vscode from "vscode";
import * as yaml from "js-yaml";
import { discoverInstalledExtensions, formatExtensionId } from "@quarto-wizard/core";
import { discoverInstalledExtensions, formatExtensionId, formatType } from "@quarto-wizard/core";
import type { SchemaCache, ExtensionSchema, FieldDescriptor } from "@quarto-wizard/core";
import { getYamlIndentLevel } from "../utils/yamlPosition";
import { logMessage } from "../utils/log";
Expand Down Expand Up @@ -287,7 +287,7 @@ export class YamlDiagnosticsProvider implements vscode.Disposable {
diagnostics.push(
new vscode.Diagnostic(
range,
`Option "${key}": expected type "${descriptor.type}", got ${typeError} ${valueStr}.`,
`Option "${key}": expected type "${formatType(descriptor.type)}", got ${typeError} ${valueStr}.`,
vscode.DiagnosticSeverity.Error,
),
);
Expand Down Expand Up @@ -407,9 +407,21 @@ export class YamlDiagnosticsProvider implements vscode.Disposable {

private static readonly KNOWN_TYPES = new Set(["string", "number", "boolean", "array", "object"]);

private checkType(value: unknown, expectedType: string): string | undefined {
private checkType(value: unknown, expectedType: string | string[]): string | undefined {
if (Array.isArray(expectedType)) {
const knownTypes = expectedType.filter((t) => YamlDiagnosticsProvider.KNOWN_TYPES.has(t));
if (knownTypes.length === 0) {
return undefined;
}
for (const t of knownTypes) {
if (this.checkType(value, t) === undefined) {
return undefined;
}
}
return Array.isArray(value) ? "array" : typeof value;
}

if (!YamlDiagnosticsProvider.KNOWN_TYPES.has(expectedType)) {
// Unknown type in schema; skip validation rather than producing false positives.
return undefined;
}

Expand Down
4 changes: 2 additions & 2 deletions src/providers/yamlHoverProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as vscode from "vscode";
import { discoverInstalledExtensions, formatExtensionId, getExtensionTypes } from "@quarto-wizard/core";
import { discoverInstalledExtensions, formatExtensionId, getExtensionTypes, formatType } from "@quarto-wizard/core";
import type {
SchemaCache,
ExtensionSchema,
Expand Down Expand Up @@ -259,7 +259,7 @@ export class YamlHoverProvider implements vscode.HoverProvider {

const details: string[] = [];
if (descriptor.type) {
details.push(`**Type:** \`${descriptor.type}\``);
details.push(`**Type:** \`${formatType(descriptor.type)}\``);
}
if (descriptor.required) {
details.push("**Required:** yes");
Expand Down
Loading