Expand one-of keys to multiple completion items (#261)

* Expand one-of keys to multiple completion items

Some workflow fields accept multiple YAML structures (scalar, sequence, or
mapping), but completions previously only showed a single option—leaving users
unaware of the full schema flexibility. This change surfaces structural options
and inserts the correct YAML scaffolding so users land in the right place to
keep typing.

Example: runs-on

Completing runs-on now shows three options:
- runs-on         → Ready for a string like ubuntu-latest
- runs-on (list)  → Ready to add runner labels
- runs-on (full syntax) → Ready for labels:, group:, etc.

Notes:
- Qualifiers (list) and (full syntax) only appear when multiple structural types exist
- Scalar completions use the plain key name
- Qualified variants use filterText matching the base key

* Sort expanded one-of completions: scalar, list, full syntax
This commit is contained in:
eric sciple
2025-12-22 10:49:49 -06:00
committed by GitHub
parent 8b86b48961
commit 9bb4c76612
5 changed files with 203 additions and 16 deletions
+84 -7
View File
@@ -44,7 +44,7 @@ jobs:
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(9);
expect(result.length).toEqual(13);
expect(result[0].label).toEqual("concurrency");
});
@@ -70,7 +70,7 @@ jobs:
|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(21);
expect(result.length).toEqual(30);
});
it("string definition completion in sequence", async () => {
@@ -243,7 +243,7 @@ jobs:
runs-|`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(21);
expect(result).toHaveLength(30);
});
it("job key with comment afterwards", async () => {
@@ -254,7 +254,7 @@ jobs:
#`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(21);
expect(result).toHaveLength(30);
});
it("job key with other values afterwards", async () => {
@@ -266,7 +266,7 @@ jobs:
concurrency: 'group-name'`;
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result).toHaveLength(20);
expect(result).toHaveLength(29);
});
it("step key without space after colon", async () => {
@@ -335,7 +335,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(17);
expect(result).toHaveLength(25);
});
it("complete from behind a colon will replace it", async () => {
@@ -348,7 +348,7 @@ jobs:
- uses: actions/checkout@v2
`;
const result = await complete(...getPositionFromCursor(input));
expect(result).toHaveLength(17);
expect(result).toHaveLength(25);
const textEdit = result[0].textEdit as TextEdit;
expect(textEdit.range).toEqual({
start: {line: 5, character: 4},
@@ -533,4 +533,81 @@ jobs:
expect(result.filter(x => x.label === "actions").map(x => x.textEdit?.newText)).toEqual(["\n actions: "]);
expect(result.filter(x => x.label === "contents").map(x => x.textEdit?.newText)).toEqual(["\n contents: "]);
});
it("shows both simple and full syntax for null+mapping one-of", async () => {
// check_run is a one-of: [null, mapping]. Show both:
// - check_run (simple, just the key with colon)
// - check_run (full syntax) (ready to add mapping keys)
const input = "on:\n |";
const result = await complete(...getPositionFromCursor(input));
// Should have both check_run and check_run (full syntax)
expect(result.some(x => x.label === "check_run")).toBe(true);
expect(result.some(x => x.label === "check_run (full syntax)")).toBe(true);
});
it("shows all three variants for scalar+sequence+mapping one-of", async () => {
// runs-on is a one-of: [string, sequence, mapping]
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
// Should have runs-on, runs-on (list), and runs-on (full syntax)
expect(result.some(x => x.label === "runs-on")).toBe(true);
expect(result.some(x => x.label === "runs-on (list)")).toBe(true);
expect(result.some(x => x.label === "runs-on (full syntax)")).toBe(true);
});
it("generates correct insertText for one-of variants in parent mode", async () => {
// runs-on is a one-of: [string, sequence, mapping]
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
// Scalar: just key with colon and space
expect(result.find(x => x.label === "runs-on")?.textEdit?.newText).toEqual("runs-on: ");
// Sequence: key with colon, newline, and list item
expect(result.find(x => x.label === "runs-on (list)")?.textEdit?.newText).toEqual("runs-on:\n - ");
// Mapping: key with colon, newline, and indentation for nested keys
expect(result.find(x => x.label === "runs-on (full syntax)")?.textEdit?.newText).toEqual("runs-on:\n ");
});
it("generates correct insertText for one-of variants in key mode", async () => {
// concurrency is a one-of: [string, mapping] - testing key mode (after colon on same line)
const input = "concurrency: |";
const result = await complete(...getPositionFromCursor(input));
// Scalar in key mode: newline + indented key + colon + space
expect(result.find(x => x.label === "group")?.textEdit?.newText).toEqual("\n group: ");
// Boolean in key mode (cancel-in-progress): newline + indented key + colon + space
expect(result.find(x => x.label === "cancel-in-progress")?.textEdit?.newText).toEqual("\n cancel-in-progress: ");
});
it("uses base key as filterText for qualified one-of variants", async () => {
// runs-on has multiple structural types, so variants get qualifiers
const input = `on: push
jobs:
build:
|`;
const result = await complete(...getPositionFromCursor(input));
// Scalar: no qualifier, so no filterText needed
expect(result.find(x => x.label === "runs-on")?.filterText).toBeUndefined();
// Sequence and mapping: qualified labels should filter on base key
expect(result.find(x => x.label === "runs-on (list)")?.filterText).toEqual("runs-on");
expect(result.find(x => x.label === "runs-on (full syntax)")?.filterText).toEqual("runs-on");
});
});
+3 -1
View File
@@ -129,6 +129,8 @@ export async function complete(
const item: CompletionItem = {
label: value.label,
filterText: value.filterText,
sortText: value.sortText,
documentation: value.description && {
kind: "markdown",
value: value.description
@@ -253,7 +255,7 @@ function getExpressionCompletionItems(
function filterAndSortCompletionOptions(options: Value[], existingValues?: Set<string>) {
options = options.filter(x => !existingValues?.has(x.label));
options.sort((a, b) => a.label.localeCompare(b.label));
options.sort((a, b) => (a.sortText ?? a.label).localeCompare(b.sortText ?? b.label));
return options;
}
+5 -1
View File
@@ -21,17 +21,21 @@ describe("end-to-end", () => {
const result = await complete(...getPositionFromCursor(input));
expect(result).not.toBeUndefined();
expect(result.length).toEqual(9);
expect(result.length).toEqual(13);
const labels = result.map(x => x.label);
expect(labels).toEqual([
"concurrency",
"concurrency (full syntax)",
"defaults",
"description",
"env",
"jobs",
"name",
"on",
"on (list)",
"on (full syntax)",
"permissions",
"permissions (full syntax)",
"run-name"
]);
});
@@ -12,6 +12,12 @@ export interface Value {
/** Alternative insert text, if not given `label` will be used */
insertText?: string;
/** Alternative filter text, if not given `label` will be used for filtering */
filterText?: string;
/** Sort text to control ordering, if not given `label` will be used for sorting */
sortText?: string;
}
export enum ValueProviderKind {
@@ -91,13 +91,13 @@ function mappingValues(
}
break;
case DefinitionType.OneOf:
if (mode == DefinitionValueMode.Key) {
insertText = `\n${indentation}${key}: `;
} else {
insertText = `${key}: `;
}
break;
case DefinitionType.OneOf: {
// Expand one-of into multiple completions based on structural type
const oneOfDef = typeDef as OneOfDefinition;
const expanded = expandOneOfToCompletions(oneOfDef, definitions, key, description, indentation, mode);
properties.push(...expanded);
continue; // Skip the default push below
}
case DefinitionType.String:
case DefinitionType.Boolean:
@@ -143,3 +143,101 @@ function distinctValues(values: Value[]): Value[] {
}
return Array.from(map.values());
}
/**
* Bucket type for one-of expansion
*/
type StructuralBucket = "scalar" | "sequence" | "mapping";
/**
* Get the structural bucket for a definition type.
* Nested one-of is treated as scalar.
*/
function getStructuralBucket(defType: DefinitionType): StructuralBucket {
switch (defType) {
case DefinitionType.Sequence:
return "sequence";
case DefinitionType.Mapping:
return "mapping";
default:
// String, Boolean, Number, Null, OneOf (nested), AllowedValues -> scalar
// Note, nested OneOf is assumed to be all scalar values, which is true in practice.
return "scalar";
}
}
/**
* Expand a one-of definition into multiple completion items based on structural types.
* Returns one completion per unique structural type (scalar, sequence, mapping).
*/
function expandOneOfToCompletions(
oneOfDef: OneOfDefinition,
definitions: {[key: string]: Definition},
key: string,
description: string | undefined,
indentation: string,
mode: DefinitionValueMode
): Value[] {
// Bucket variants by structural type
const buckets: Record<StructuralBucket, boolean> = {
scalar: false,
sequence: false,
mapping: false
};
for (const variantKey of oneOfDef.oneOf) {
const variantDef = definitions[variantKey];
if (variantDef) {
const bucket = getStructuralBucket(variantDef.definitionType);
buckets[bucket] = true;
}
}
const results: Value[] = [];
// Count how many structural types are present
const bucketCount = [buckets.scalar, buckets.sequence, buckets.mapping].filter(Boolean).length;
const needsQualifier = bucketCount > 1;
// Emit completions in order: scalar, sequence, mapping
// Use sortText to preserve this order (scalar sorts first, then 1=sequence, 2=mapping)
if (buckets.scalar) {
// In Key mode, insert newline and indentation to produce valid YAML structure
const insertText = mode === DefinitionValueMode.Key ? `\n${indentation}${key}: ` : `${key}: `;
results.push({
label: key,
description,
insertText
});
}
if (buckets.sequence) {
const insertText =
mode === DefinitionValueMode.Key
? `\n${indentation}${key}:\n${indentation}${indentation}- `
: `${key}:\n${indentation}- `;
results.push({
label: needsQualifier ? `${key} (list)` : key,
description,
insertText,
filterText: needsQualifier ? key : undefined,
sortText: needsQualifier ? `${key} 1` : undefined
});
}
if (buckets.mapping) {
const insertText =
mode === DefinitionValueMode.Key
? `\n${indentation}${key}:\n${indentation}${indentation}`
: `${key}:\n${indentation}`;
results.push({
label: needsQualifier ? `${key} (full syntax)` : key,
description,
insertText,
filterText: needsQualifier ? key : undefined,
sortText: needsQualifier ? `${key} 2` : undefined
});
}
return results;
}