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:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user