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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
- feat: add shortcode completion provider.
Shortcode names, positional arguments, and named attributes defined in `_schema.yml` are suggested inside `{{< >}}` delimiters.
- feat: display schema information in the installed extensions tree view, showing option counts and types per extension.
- feat: add file-path completion for shortcode arguments, YAML values, and element attributes.
When a `_schema.yml` declares `completion.type: file` (with optional `extensions` filter), the editor suggests workspace files matching the specified extensions.

### Bug Fixes

Expand Down
46 changes: 46 additions & 0 deletions packages/core/tests/filesystem/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,25 @@ describe("normaliseShortcodeSchema", () => {
expect(result.arguments).toBeUndefined();
expect(result.attributes).toBeUndefined();
});

it("preserves file-path completion spec on arguments", () => {
const result = normaliseShortcodeSchema({
description: "Include external content",
arguments: [
{
name: "file",
type: "string",
required: true,
completion: { type: "file", extensions: [".md", ".qmd"] },
},
],
});

expect(result.arguments).toHaveLength(1);
expect(result.arguments![0].completion).toBeDefined();
expect(result.arguments![0].completion!.type).toBe("file");
expect(result.arguments![0].completion!.extensions).toEqual([".md", ".qmd"]);
});
});

describe("normaliseSchema", () => {
Expand Down Expand Up @@ -342,6 +361,33 @@ formats:
expect(schema.formats!["pdf"]["margin"].default).toBe("1in");
});

it("parses shortcode argument with file-path completion spec", () => {
const yamlContent = `
shortcodes:
external:
description: Include external content
arguments:
- name: file
type: string
required: true
completion:
type: file
extensions:
- .md
- .qmd
`;

const schema = parseSchemaContent(yamlContent);

expect(schema.shortcodes).toBeDefined();
expect(schema.shortcodes!["external"].arguments).toHaveLength(1);
const arg = schema.shortcodes!["external"].arguments![0];
expect(arg.name).toBe("file");
expect(arg.completion).toBeDefined();
expect(arg.completion!.type).toBe("file");
expect(arg.completion!.extensions).toEqual([".md", ".qmd"]);
});

it("throws SchemaError on empty content", () => {
expect(() => parseSchemaContent("")).toThrow(SchemaError);
});
Expand Down
16 changes: 13 additions & 3 deletions src/providers/elementAttributeCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { FieldDescriptor, SchemaCache, ExtensionSchema } from "@quarto-wiza
import { discoverInstalledExtensions } from "@quarto-wizard/core";
import { parseAttributeAtPosition, type PandocElementType } from "../utils/elementAttributeParser";
import { getWordAtOffset, hasCompletableValues, buildAttributeDoc } from "../utils/schemaDocumentation";
import { isFilePathDescriptor, buildFilePathCompletions } from "../utils/filePathCompletion";
import { logMessage } from "../utils/log";

/**
Expand Down Expand Up @@ -193,7 +194,7 @@ export class ElementAttributeCompletionProvider implements vscode.CompletionItem
return this.completeAttributeKey(applicable, parsed.attributes);

case "attributeValue":
return this.completeAttributeValue(applicable, parsed.currentAttributeKey);
return this.completeAttributeValue(applicable, parsed.currentAttributeKey, document.uri);

default:
return null;
Expand Down Expand Up @@ -262,10 +263,11 @@ export class ElementAttributeCompletionProvider implements vscode.CompletionItem
return items;
}

private completeAttributeValue(
private async completeAttributeValue(
attributes: Record<string, AttributeWithSource>,
attributeKey: string | undefined,
): vscode.CompletionItem[] {
documentUri: vscode.Uri,
): Promise<vscode.CompletionItem[]> {
if (!attributeKey) {
return [];
}
Expand Down Expand Up @@ -301,6 +303,14 @@ export class ElementAttributeCompletionProvider implements vscode.CompletionItem
}
}

if (isFilePathDescriptor(descriptor)) {
const fileItems = await buildFilePathCompletions(descriptor, documentUri);
for (const fileItem of fileItems) {
fileItem.detail = source;
items.push(fileItem);
}
}

if (descriptor.type === "boolean" && items.length === 0) {
for (const label of ["true", "false"]) {
const item = new vscode.CompletionItem(label, vscode.CompletionItemKind.Value);
Expand Down
31 changes: 21 additions & 10 deletions src/providers/shortcodeCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ShortcodeSchema, FieldDescriptor, SchemaCache } from "@quarto-wiza
import { discoverInstalledExtensions } from "@quarto-wizard/core";
import { parseShortcodeAtPosition } from "../utils/shortcodeParser";
import { getWordAtOffset, hasCompletableValues, buildAttributeDoc } from "../utils/schemaDocumentation";
import { isFilePathDescriptor, buildFilePathCompletions } from "../utils/filePathCompletion";
import { logMessage } from "../utils/log";

/**
Expand Down Expand Up @@ -55,10 +56,10 @@ export class ShortcodeCompletionProvider implements vscode.CompletionItemProvide
return this.completeAttributeKey(schemas, parsed.name, parsed.attributes);

case "attributeValue":
return this.completeAttributeValue(schemas, parsed.name, parsed.currentAttributeKey);
return this.completeAttributeValue(schemas, parsed.name, parsed.currentAttributeKey, document.uri);

case "argument": {
const argItems = this.completeArgument(schemas, parsed.name, parsed.arguments);
const argItems = await this.completeArgument(schemas, parsed.name, parsed.arguments, document.uri);
const attrItems = this.completeAttributeKey(schemas, parsed.name, parsed.attributes);
return [...argItems, ...attrItems];
}
Expand Down Expand Up @@ -172,11 +173,12 @@ export class ShortcodeCompletionProvider implements vscode.CompletionItemProvide
/**
* Suggest values for the given attribute.
*/
private completeAttributeValue(
private async completeAttributeValue(
schemas: Map<string, ShortcodeSchema>,
name: string | null,
attributeKey: string | undefined,
): vscode.CompletionItem[] {
documentUri: vscode.Uri,
): Promise<vscode.CompletionItem[]> {
if (!name || !attributeKey) {
return [];
}
Expand All @@ -191,17 +193,18 @@ export class ShortcodeCompletionProvider implements vscode.CompletionItemProvide
return [];
}

return this.buildValueCompletions(descriptor);
return this.buildValueCompletions(descriptor, documentUri);
}

/**
* Suggest positional argument values.
*/
private completeArgument(
private async completeArgument(
schemas: Map<string, ShortcodeSchema>,
name: string | null,
existingArgs: string[],
): vscode.CompletionItem[] {
documentUri: vscode.Uri,
): Promise<vscode.CompletionItem[]> {
if (!name) {
return [];
}
Expand All @@ -217,7 +220,7 @@ export class ShortcodeCompletionProvider implements vscode.CompletionItemProvide
}

const argDescriptor = schema.arguments[argIndex];
const items = this.buildValueCompletions(argDescriptor);
const items = await this.buildValueCompletions(argDescriptor, documentUri);

// Trigger the next completion automatically after accepting a positional argument.
for (const item of items) {
Expand All @@ -228,9 +231,12 @@ export class ShortcodeCompletionProvider implements vscode.CompletionItemProvide
}

/**
* Build completion items from a field descriptor's enum or completion spec.
* Build completion items from a field descriptor's enum, completion spec, or file paths.
*/
private buildValueCompletions(descriptor: FieldDescriptor): vscode.CompletionItem[] {
private async buildValueCompletions(
descriptor: FieldDescriptor,
documentUri: vscode.Uri,
): Promise<vscode.CompletionItem[]> {
const items: vscode.CompletionItem[] = [];

if (descriptor.enum) {
Expand All @@ -255,6 +261,11 @@ export class ShortcodeCompletionProvider implements vscode.CompletionItemProvide
}
}

if (isFilePathDescriptor(descriptor)) {
const fileItems = await buildFilePathCompletions(descriptor, documentUri);
items.push(...fileItems);
}

if (descriptor.type === "boolean" && items.length === 0) {
items.push(new vscode.CompletionItem("true", vscode.CompletionItemKind.Value));
items.push(new vscode.CompletionItem("false", vscode.CompletionItemKind.Value));
Expand Down
43 changes: 29 additions & 14 deletions src/providers/yamlCompletionProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import * as vscode from "vscode";
import { discoverInstalledExtensions, formatExtensionId, getExtensionTypes } 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";
import { hasCompletableValues } from "../utils/schemaDocumentation";
import { logMessage } from "../utils/log";

/**
Expand Down Expand Up @@ -73,7 +75,7 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {
return this.completeTopLevelKeys(schemaMap, existingKeys);
}

const items = this.resolveCompletions(keyPath, schemaMap, extMap, existingKeys);
const items = await this.resolveCompletions(keyPath, schemaMap, extMap, existingKeys, document.uri);
if (!items || items.length === 0) {
return undefined;
}
Expand Down Expand Up @@ -108,7 +110,7 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {
const isKey = item.kind === vscode.CompletionItemKind.Module || item.kind === vscode.CompletionItemKind.Property;

if (!isKey) {
// Value completions (enum, boolean): set the range so the
// Value completions (enum, boolean, file): set the range so the
// leading space in insertText replaces any existing whitespace
// between the colon and the cursor.
item.range = replaceRange;
Expand Down Expand Up @@ -146,12 +148,13 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {
return [item];
}

private resolveCompletions(
private async resolveCompletions(
keyPath: string[],
schemaMap: Map<string, ExtensionSchema>,
extMap: Map<string, InstalledExtension>,
existingKeys: Set<string>,
): vscode.CompletionItem[] | undefined {
documentUri: vscode.Uri,
): Promise<vscode.CompletionItem[] | undefined> {
if (keyPath.length === 0) {
return undefined;
}
Expand All @@ -175,7 +178,7 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {
return this.completeFieldKeys(schema.options, existingKeys);
}

return this.completeNestedFieldKeys(schema.options, keyPath.slice(2), existingKeys);
return this.completeNestedFieldKeys(schema.options, keyPath.slice(2), existingKeys, documentUri);
}

// Under "format.<format-name>:" suggest format-specific keys.
Expand All @@ -190,7 +193,7 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {
return this.completeFieldKeys(formatFields, existingKeys);
}

return this.completeNestedFieldKeys(formatFields, keyPath.slice(2), existingKeys);
return this.completeNestedFieldKeys(formatFields, keyPath.slice(2), existingKeys, documentUri);
}

return undefined;
Expand Down Expand Up @@ -267,11 +270,12 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {
return items.length > 0 ? items : undefined;
}

private completeNestedFieldKeys(
private async completeNestedFieldKeys(
fields: Record<string, FieldDescriptor> | undefined,
remainingPath: string[],
existingKeys: Set<string>,
): vscode.CompletionItem[] | undefined {
documentUri: vscode.Uri,
): Promise<vscode.CompletionItem[] | undefined> {
if (!fields || remainingPath.length === 0) {
return this.completeFieldKeys(fields, existingKeys);
}
Expand All @@ -285,14 +289,17 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {

// If the descriptor has properties (type "object"), walk deeper.
if (descriptor.properties) {
return this.completeNestedFieldKeys(descriptor.properties, remainingPath.slice(1), existingKeys);
return this.completeNestedFieldKeys(descriptor.properties, remainingPath.slice(1), existingKeys, documentUri);
}

// At a leaf: suggest enum values or boolean values.
return this.completeFieldValues(descriptor);
// At a leaf: suggest enum values, boolean values, or file paths.
return this.completeFieldValues(descriptor, documentUri);
}

private completeFieldValues(descriptor: FieldDescriptor): vscode.CompletionItem[] | undefined {
private async completeFieldValues(
descriptor: FieldDescriptor,
documentUri: vscode.Uri,
): Promise<vscode.CompletionItem[] | undefined> {
const items: vscode.CompletionItem[] = [];

if (descriptor.enum) {
Expand All @@ -317,6 +324,15 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {
}
}

if (isFilePathDescriptor(descriptor)) {
const fileItems = await buildFilePathCompletions(descriptor, documentUri);
for (const fileItem of fileItems) {
fileItem.insertText = ` ${typeof fileItem.label === "string" ? fileItem.label : fileItem.label.label}`;
fileItem.filterText = typeof fileItem.label === "string" ? fileItem.label : fileItem.label.label;
items.push(fileItem);
}
}

return items.length > 0 ? items : undefined;
}

Expand Down Expand Up @@ -364,8 +380,7 @@ export class YamlCompletionProvider implements vscode.CompletionItemProvider {
} else {
item.insertText = `${key}: `;
// Chain to value completions for fields with known values.
const hasCompletableValues = descriptor.enum || descriptor.type === "boolean";
if (hasCompletableValues) {
if (hasCompletableValues(descriptor)) {
item.command = { command: "editor.action.triggerSuggest", title: "Trigger Suggest" };
}
}
Expand Down
Loading