Skip to content
Open
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
31 changes: 31 additions & 0 deletions src/parsers/parameterDefaultShape.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Shared type-guard for "is this value usable as an input's `default:`?".
*
* `vscode`-free and pure so the Mocha unit suite (and `specParser`, which must load under plain Node) can import it
* directly. Both spec parsers narrow the same union, so they share one definition rather than a copy each.
*/

import type { ParameterDefault } from '../types/git-component';

/**
* Narrow an unknown value to {@link ParameterDefault}, the union accepted by an input's `default:` field.
*
* Guards the boundary where a parsed spec becomes a typed parameter: YAML and the catalog API both hand back
* `unknown`, and anything outside the union (a mapping, a nested array) is rejected rather than carried forward as
* a default no consumer can render.
*
* @param value - A value of unknown shape, typically straight from a YAML parse or an API response.
* @returns `true` when `value` is a string, number, boolean, `null`, or an array of those primitives.
*/
export function isParameterDefault(value: unknown): value is ParameterDefault {
if (value === null) return true;
const t = typeof value;
if (t === 'string' || t === 'number' || t === 'boolean') return true;
if (Array.isArray(value)) {
return value.every(v => {
const vt = typeof v;
return vt === 'string' || vt === 'number' || vt === 'boolean';
});
}
return false;
}
152 changes: 129 additions & 23 deletions src/parsers/specParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,100 @@ import { Logger } from '../utils/logger';
// Import direct from errors/types (not the barrel) so this module can load from plain Node — the barrel re-exports
// from errors/handler.ts which imports `vscode` at module load. Required for the Mocha unit suite.
import { ParseError } from '../errors/types';
import type { ParameterDefault } from '../types/git-component';
import { isParameterDefault } from './parameterDefaultShape';
import * as yaml from 'js-yaml';
import { isYamlNode, GITLAB_CI_SCHEMA } from '../utils/yamlParser';

const logger = Logger.getInstance();

/** An input's own keys, which are never input names however the spec happens to be indented. */
const INPUT_FIELD_KEYS = new Set(['description', 'default', 'type', 'options', 'regex']);

/**
* Resolve one already-trimmed scalar to the value YAML reads it as.
*
* @param trimmed - A single scalar's text, surrounding whitespace already removed.
* @returns The parsed value, or the text with surrounding quotes stripped when it isn't a parseable YAML scalar.
*/
function parseScalar(trimmed: string): ParameterDefault {
try {
// `yaml.load` directly rather than `parseYaml`: these probes are one-off strings, so memoising them only evicts
// the document parses sharing that cache.
const parsed = yaml.load(`probe: ${trimmed}`, { schema: GITLAB_CI_SCHEMA });
if (isYamlNode(parsed) && isParameterDefault(parsed.probe)) {
return parsed.probe;
}
} catch {
// Not valid YAML on its own (an unquoted `*`, a stray `{`, …) — fall back to the literal text.
}
return trimmed.replace(/^["']|["']$/g, '').trim();
}

/**
* Parse the text after `default:` into the value the input's type says it is.
*
* This parser reads the spec line-by-line rather than as a document, so a `default:` value arrives as raw text. Left
* as text, `default: false` on a `type: boolean` input becomes the string `"false"`, which downstream renders back
* as a quoted `"false"` — a string where GitLab expects a boolean.
*
* A `string` input's default is text by declaration, so it is never sent through YAML: `default: 1.0` stays `"1.0"`
* and `0755` keeps its leading zero. That covers an omitted `type:` as well — GitLab resolves an untyped input as
* `StringInput` (its `matches?` accepts a spec with no `:type` key) and coerces the default with `to_s`, so an
* untyped default is a string no matter what it looks like.
*
* @param rawValue - The text following `default:` on the line, e.g. `false`, `"0"`, `[a, b]`.
* @param inputType - The input's resolved `type:`; `'string'` when the spec omits it.
* @returns The parsed value, or the trimmed raw text when it doesn't parse as a YAML scalar.
*/
function parseDefaultValue(rawValue: string, inputType: string): ParameterDefault {
const trimmed = rawValue.trim();
// An empty `default:` is an explicit empty string, not an absent default — absent inputs are marked required.
if (trimmed.length === 0) {
return '';
}
if (inputType === 'string') {
return trimmed.replace(/^["']|["']$/g, '').trim();
}
return parseScalar(trimmed);
}

/**
* Parse one `options:` entry into the value YAML says it is.
*
* Kept in step with {@link parseDefaultValue}: an entry and a default that read the same in the spec must produce
* the same value, or a `default: false` never matches the `false` in `options: [true, false]` and fails to
* pre-select. A `string` input — declared or by omission — keeps entries literal for the same reason defaults do.
*
* @param entry - One option's raw text, quotes and surrounding whitespace included.
* @param inputType - The input's resolved `type:`; `'string'` when the spec omits it.
* @returns The entry as a string, number, or boolean.
*/
function parseOptionEntry(entry: string, inputType: string): string | number | boolean {
const trimmed = entry.trim();
if (inputType === 'string') {
return trimmed.replace(/^["']|["']$/g, '').trim();
}
const parsed = parseScalar(trimmed);
// `options:` entries are scalars; a null or nested-array entry isn't meaningful, so keep those as their text.
return parsed === null || Array.isArray(parsed) ? trimmed.replace(/^["']|["']$/g, '').trim() : parsed;
}

export interface ComponentVariable {
name: string;
description: string;
required: boolean;
type: string;
default?: string;
/** Allowed values from the input's `options:` list, in declaration order; absent when no `options:` is given. */
options?: string[];
/**
* The input's `default:`, as the value YAML says it is — `default: false` is the boolean `false`, not `"false"`.
* Consumers render it back to YAML by type, so a string default here must be a genuine string.
*/
default?: ParameterDefault;
/**
* Allowed values from the input's `options:` list, in declaration order; absent when no `options:` is given.
* Entries carry their YAML types, so they compare equal to a `default` that names one of them.
*/
options?: Array<string | number | boolean>;
}

export interface ParsedSpec {
Expand All @@ -26,11 +109,12 @@ export interface ParsedSpec {
* Parse the inline form of an `options:` value (`options: [a, "b", c]`).
*
* Returns an empty array for the expanded form (`options:` with the values on following `- item` lines), which the
* caller then fills in as it reads those lines. Surrounding brackets and per-entry quotes are stripped; blank entries
* (e.g. a trailing comma) are dropped.
* caller then fills in as it reads those lines. Surrounding brackets are stripped and blank entries (e.g. a trailing
* comma) are dropped, but each entry stays raw text — {@link parseOptionEntry} types it once the input's `type:` is
* known, which may be declared after `options:`.
*
* @param rawValue - The text after `options:` on the same line, e.g. `[a, "b", c]` (or empty for the expanded form).
* @returns The parsed option values, or an empty array when the value isn't an inline `[...]` list.
* @returns The raw option entries, or an empty array when the value isn't an inline `[...]` list.
*/
function parseInlineOptions(rawValue: string): string[] {
if (!rawValue.startsWith('[')) {
Expand All @@ -39,7 +123,7 @@ function parseInlineOptions(rawValue: string): string[] {
return rawValue
.replace(/^\[|\]$/g, '')
.split(',')
.map(entry => entry.trim().replace(/^["']|["']$/g, '').trim())
.map(entry => entry.trim())
.filter(entry => entry.length > 0);
}

Expand Down Expand Up @@ -146,6 +230,31 @@ export class GitLabSpecParser {
.filter(line => line.trim() && !line.trim().startsWith('#'));

let currentInput: ComponentVariable | null = null;
// The `default:` text for `currentInput`, held until the input ends so the declared `type:` can steer the parse.
let rawDefault: string | null = null;
// Raw `options:` entries for `currentInput`, typed by `finalizeInput` for the same reason as `rawDefault`.
let rawOptions: string[] | null = null;

/**
* Resolve the pending `default:` and `options:` against the declared type and mark a defaultless input
* required. Deferred to here because `type:` may be declared after either of them.
*/
const finalizeInput = (input: ComponentVariable): ComponentVariable => {
// An omitted `type:` is `string` to GitLab, and `input.type` already holds that fallback.
if (rawDefault !== null) {
input.default = parseDefaultValue(rawDefault, input.type);
} else {
// GitLab CI/CD component behavior: an input with no default is required.
input.required = true;
}
if (rawOptions !== null) {
// Typed against the input's type so an entry matches a `default` naming the same value.
input.options = rawOptions.map(entry => parseOptionEntry(entry, input.type));
}
rawDefault = null;
rawOptions = null;
return input;
};

for (const line of inputLines) {
const trimmedLine = line.trim();
Expand All @@ -162,14 +271,12 @@ export class GitLabSpecParser {
// The name class includes `-`: GitLab input names are commonly hyphenated (e.g. `job-name`). Without
// it, a hyphenated key isn't recognised as a new input, so its `description:`/`default:` lines bleed
// onto the previous (non-hyphenated) input and mis-map every field after it (issue #211).
if (line.match(/^\s{2,4}[a-zA-Z_][a-zA-Z0-9_-]*:\s*$/)) {
// An input's own keys are excluded by name: on a spec indented `inputs:` at 0, they sit at 4 and would
// otherwise match, so a valueless `default:` would open a phantom input and swallow the real one's value.
if (line.match(/^\s{2,4}[a-zA-Z_][a-zA-Z0-9_-]*:\s*$/) && !INPUT_FIELD_KEYS.has(trimmedLine.slice(0, -1))) {
// If we have a current input, finalize it before starting a new one
if (currentInput) {
// Mark as required if no default was specified (GitLab CI/CD component behavior)
if (currentInput.default === undefined) {
currentInput.required = true;
}
extractedVariables.push(currentInput);
extractedVariables.push(finalizeInput(currentInput));
}
const inputName = trimmedLine.split(':')[0];
currentInput = {
Expand All @@ -186,27 +293,25 @@ export class GitLabSpecParser {
if (trimmedLine.startsWith('description:')) {
currentInput.description = trimmedLine.substring(12).replace(/^["']|["']$/g, '').trim();
} else if (trimmedLine.startsWith('default:')) {
currentInput.default = trimmedLine.substring(8).replace(/^["']|["']$/g, '').trim();
// Kept as text until the input is complete: `type:` may follow `default:`, and the declared type decides
// whether the value is parsed as YAML or kept literal. Resolved by `finalizeInput`.
rawDefault = trimmedLine.substring(8);
} else if (trimmedLine.startsWith('type:')) {
currentInput.type = trimmedLine.substring(5).replace(/^["']|["']$/g, '').trim();
} else if (trimmedLine.startsWith('options:')) {
// Open the options list. An inline form (`options: [a, b]`) carries its values on the same line;
// the expanded form leaves them for the `- item` lines below.
currentInput.options = parseInlineOptions(trimmedLine.substring(8).trim());
} else if (trimmedLine.startsWith('- ') && currentInput.options) {
rawOptions = parseInlineOptions(trimmedLine.substring(8).trim());
} else if (trimmedLine.startsWith('- ') && rawOptions) {
// Expanded list item belonging to the open `options:` block.
currentInput.options.push(trimmedLine.substring(2).replace(/^["']|["']$/g, '').trim());
rawOptions.push(trimmedLine.substring(2).trim());
}
}
}

// Add the last input
if (currentInput) {
// Mark as required if no default was specified (GitLab CI/CD component behavior)
if (currentInput.default === undefined) {
currentInput.required = true;
}
extractedVariables.push(currentInput);
extractedVariables.push(finalizeInput(currentInput));
}

logger.debug(`${logPrefix} Extracted ${extractedVariables.length} input parameters from spec`, 'SpecParser');
Expand Down Expand Up @@ -274,7 +379,8 @@ export class GitLabSpecParser {
description: `Parameter: ${varName}`,
required: false,
type: 'string',
default: defaultValue || undefined
// The legacy format declares no per-variable type, so values stay the text they appear as.
default: defaultValue ? parseDefaultValue(defaultValue, 'string') : undefined
};
});

Expand Down
66 changes: 51 additions & 15 deletions src/providers/completionInputContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@

import { parseYaml, parseYamlDocuments, findDocumentWith, isYamlNode, type YamlNode } from '../utils/yamlParser';
import { findIncludeLine } from '../utils/includeMatcher';
import type { ComponentParameter } from '../types/git-component';
import type { ComponentParameter, ParameterDefault } from '../types/git-component';

/**
* The completion slot a YAML cursor position resolves to.
Expand Down Expand Up @@ -324,6 +324,22 @@ export function renderOptionValue(value: string | number | boolean): string {
return typeof value === 'string' ? quoteYamlIfUnsafe(value) : String(value);
}

/**
* Render an input's `default` as the YAML text to insert for it.
*
* Arrays become flow sequences (`[a, b]`); strings are bare or double-quoted by round-trip safety; everything else
* (booleans, numbers, `null`) is its bare scalar form, so a `false` default inserts as `false` and not `"false"`.
*
* @param value - The declared default for the input.
* @returns The YAML text for that value.
*/
export function renderDefaultValue(value: ParameterDefault): string {
if (Array.isArray(value)) {
return `[${value.map((v) => (typeof v === 'string' ? quoteYamlIfUnsafe(v, true) : String(v))).join(', ')}]`;
}
return typeof value === 'string' ? quoteYamlIfUnsafe(value) : String(value);
}

/**
* Narrow a parameter default to the scalar shapes an `options:` entry can take (string/number/boolean), excluding
* the `null` and array forms a default may also hold.
Expand All @@ -335,6 +351,28 @@ function isOptionScalar(value: unknown): value is string | number | boolean {
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
}

/**
* The values an input is allowed to take, if that set is known and finite.
*
* An explicit `options:` list is used as declared. A `boolean` input has no `options:` in the spec but is still a
* closed two-value enum, so it yields `true, false` — conventional reading order, which is what a reader scanning
* the dropdown expects. A declared default is floated to the front by the caller, so it still pre-selects.
*
* Shared by both completion slots so the input-name snippet and the value-slot dropdown always agree.
*
* @param param - The input being completed.
* @returns The allowed values, or `undefined` when the input accepts free-form text.
*/
export function allowedValuesFor(param: ComponentParameter): Array<string | number | boolean> | undefined {
if (param.options && param.options.length > 0) {
return param.options;
}
if (param.type === 'boolean') {
return [true, false];
}
return undefined;
}

/**
* Build the value portion of the snippet inserted after `param.name: ` when an input is accepted from completion.
*
Expand All @@ -343,20 +381,24 @@ function isOptionScalar(value: unknown): value is string | number | boolean {
* Values are inserted bare where a bare YAML scalar round-trips to the same string — GitLab CI parses a bare scalar
* by the input's declared type, so a string input is a bare scalar, not a quoted one. Strings that bare YAML would
* reinterpret (indicators, embedded `: `/` #`, type-like tokens, etc.) are double-quoted; see {@link quoteYamlIfUnsafe}.
* Precedence: an `options:` enum becomes a `${1|...|}` choice (with the default, if any, pre-selected first) so the
* allowed values stay one keystroke away; otherwise an explicit `default` is rendered as the YAML it represents;
* otherwise a type-appropriate placeholder.
* Precedence: an `options:` enum — or a `boolean` input, which is a closed two-value enum — becomes a `${1|...|}`
* choice (with the default, if any, pre-selected first) so the allowed values stay one keystroke away; otherwise an
* explicit `default` is rendered as the YAML it represents; otherwise a type-appropriate placeholder.
*
* @param param - The input parameter spec (type, optional default, optional `options` enum, requiredness).
* @returns The snippet body to insert after `param.name: ` — a `${1|...|}` choice, a rendered value, or a `${1:...}` placeholder.
*/
export function buildInputInsertValue(param: ComponentParameter): string {
if (param.options && param.options.length > 0) {
// Offer the allowed values (`options:`) as a choice. Entries stay unquoted so a number/boolean option isn't
// Includes a `boolean` input's implicit true/false. Falling through to the default-rendering branch below would
// pre-fill the default with no alternatives shown.
const options = allowedValuesFor(param);

if (options && options.length > 0) {
// Offer the allowed values as a choice. Entries stay unquoted so a number/boolean option isn't
// turned into a string; string entries are quoted only when bare YAML would reinterpret them. When the input
// also has a default, float the matching option to the front — VS Code pre-selects the first choice entry, so
// accepting the input keeps the default while leaving the alternatives one arrow-key away.
const rendered = param.options.map(renderOptionValue);
const rendered = options.map(renderOptionValue);
// Only a scalar default can name one of the options; a null or array default has no matching entry.
const defaultRendered = isOptionScalar(param.default) ? renderOptionValue(param.default) : undefined;
const ordered =
Expand All @@ -367,17 +409,11 @@ export function buildInputInsertValue(param: ComponentParameter): string {
}

if (param.default !== undefined) {
// Render the default as the YAML the input expects: arrays as flow sequences (`[a, b]`),
// strings bare-or-quoted by round-trip safety, everything else as its bare scalar form.
if (Array.isArray(param.default)) {
return `[${param.default.map((v) => (typeof v === 'string' ? quoteYamlIfUnsafe(v, true) : String(v))).join(', ')}]`;
}
return typeof param.default === 'string' ? quoteYamlIfUnsafe(param.default) : String(param.default);
return renderDefaultValue(param.default);
}

switch (param.type) {
case 'boolean':
return param.required ? '${1|true,false|}' : '${1|false,true|}';
// `boolean` is handled above as a two-value choice, with or without a default.
case 'number':
return '${1:0}';
case 'integer':
Expand Down
Loading
Loading