diff --git a/.ai/context.yaml b/.ai/context.yaml index bce25a4..fc6abab 100644 --- a/.ai/context.yaml +++ b/.ai/context.yaml @@ -39,6 +39,7 @@ dependencies: runtime: - vscode: VS Code Extension API - js-yaml: YAML parsing + - yaml: Source ranges for placing component inserts in the include list dev: - typescript: Type-safe development - esbuild: Fast bundling diff --git a/package-lock.json b/package-lock.json index bb75536..d8502cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,8 @@ "stylelint-config-standard": "^40.0.0", "tsx": "^4.23.15", "typescript": "^6.0.3", - "typescript-eslint": "^8.71.0" + "typescript-eslint": "^8.71.0", + "yaml": "^2.9.1" }, "engines": { "node": ">=22.0.0", @@ -10533,6 +10534,22 @@ "node": ">=10" } }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "18.0.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", diff --git a/package.json b/package.json index 95b715d..d201e9a 100644 --- a/package.json +++ b/package.json @@ -357,7 +357,8 @@ "stylelint-config-standard": "^40.0.0", "tsx": "^4.23.15", "typescript": "^6.0.3", - "typescript-eslint": "^8.71.0" + "typescript-eslint": "^8.71.0", + "yaml": "^2.9.1" }, "keywords": [], "author": "eFAILution", diff --git a/src/providers/componentBrowserProvider.ts b/src/providers/componentBrowserProvider.ts index f1bc29d..e5000bb 100644 --- a/src/providers/componentBrowserProvider.ts +++ b/src/providers/componentBrowserProvider.ts @@ -10,6 +10,7 @@ import type { HoverContext } from './hoverContentBuilder'; import { Logger } from '../utils/logger'; import { templateFileUrlForResolved } from '../utils/templateFileUrl'; import { generateComponentText } from './componentBrowserGenerate'; +import { planComponentInsertion } from './componentInsertionPoint'; import { findComponentLineRange, parseExistingComponentText } from './componentBrowserEdit'; import { chooseComponentVersion, transformCachedComponentsToGroups } from './componentBrowserTransform'; import { LATEST_VERSION_PREFERENCE, versionPreferenceKey } from './versionPreferences'; @@ -393,12 +394,19 @@ export class ComponentBrowserProvider { // Gate on declared parameters so a parameterless component doesn't get a bare `inputs:` line. const withInputs = includeInputs && (component.parameters?.length ?? 0) > 0; const serverFqdn = await resolveServerFqdn(editor.document.uri); - const insertion = generateComponentText(component, withInputs, withInputs ? selectedInputs : [], null, serverFqdn); + const snippet = generateComponentText(component, withInputs, withInputs ? selectedInputs : [], null, serverFqdn); + const planned = planComponentInsertion(editor.document.getText(), snippet); + const target = planned + ? new vscode.Range(planned.start.line, planned.start.character, planned.end.line, planned.end.character) + : new vscode.Range(editor.selection.active, editor.selection.active); + const position = target.start; + const insertion = planned?.text ?? snippet; - // Insert at cursor position - editor.edit(editBuilder => { - editBuilder.insert(editor.selection.active, insertion); + await editor.edit(editBuilder => { + editBuilder.replace(target, insertion); }); + const insertedEnd = new vscode.Position(position.line + insertion.split('\n').length - 1, 0); + editor.revealRange(new vscode.Range(position, insertedEnd), vscode.TextEditorRevealType.InCenterIfOutsideViewport); // Create appropriate success message let message = `Inserted component: ${component.name}`; diff --git a/src/providers/componentInsertionPoint.ts b/src/providers/componentInsertionPoint.ts new file mode 100644 index 0000000..3a4e85f --- /dev/null +++ b/src/providers/componentInsertionPoint.ts @@ -0,0 +1,266 @@ +/** + * Decides where the Component Browser's "Insert" places a new `- component:` entry: at the end of the file's top-level + * `include:` list, or in a new `include:` block at the top of the file when there isn't one. No `vscode` types, so the + * provider wraps the result into a `vscode.Range` and the unit suite can drive it directly. + * + * The `yaml` parser is used for its source ranges only. The edit itself is a text splice, so the rest of the user's + * file keeps its formatting, quoting and comments exactly as written. + */ + +import { + isAlias, + isMap, + isScalar, + isSeq, + LineCounter, + parseAllDocuments, + Scalar, + type Document, + type Node, + type Range, +} from 'yaml'; + +/** A 0-based line and character in the document. */ +export interface TextPosition { + line: number; + character: number; +} + +/** Replace `start`–`end` with `text`; a pure insert when the two positions are equal. */ +export interface ComponentInsertion { + start: TextPosition; + end: TextPosition; + text: string; +} + +/** The indentation `generateComponentText` gives a list item; re-based onto the target list's own indentation. */ +const SNIPPET_ITEM_INDENT = ' '; + +/** Offsets in the source text, mapped to positions and line ends. */ +class SourceText { + readonly lines: string[]; + private readonly lineCounter: LineCounter; + + constructor(readonly text: string, lineCounter: LineCounter) { + this.lines = text.split('\n').map(line => line.replace(/\r$/, '')); + this.lineCounter = lineCounter; + } + + /** 0-based line holding `offset`. */ + lineOf(offset: number): number { + return this.lineCounter.linePos(offset).line - 1; + } + + /** 0-based position of `offset`. */ + positionOf(offset: number): TextPosition { + const { line, col } = this.lineCounter.linePos(offset); + return { line: line - 1, character: col - 1 }; + } + + /** Position at the end of `line`, before any `\r`. */ + lineEnd(line: number): TextPosition { + return { line, character: this.lines[line].length }; + } +} + +/** + * Plan the insertion of `snippet` (as built by `generateComponentText`) into the document `text`. + * + * - Block `include:` list → appended after the list's last entry, re-indented to match the existing items. + * - Single `include:` entry (a scalar, flow sequence or flow mapping, or a block mapping) → rewritten as a block + * list of the existing entries followed by the new one. + * - No top-level `include:` → a new `include:` block after any leading comments, and in the pipeline document after + * a component template's `spec:` header. + * - Anything this can't rewrite without changing its meaning or layout → `null`, and the caller falls back to the + * cursor: YAML that doesn't parse, an alias, anchor or tag on the `include:` value, a block scalar, an entry that + * spans lines, or a template with no pipeline document. + * + * @param text The full document text; LF or CRLF line endings. + * @param snippet The `- component:` entry to add, with the two-space item indentation `generateComponentText` emits. + * @returns The range to replace and its replacement text, or `null` when the caller should use the cursor. + */ +export function planComponentInsertion(text: string, snippet: string): ComponentInsertion | null { + const lineCounter = new LineCounter(); + const docs = parseAllDocuments(text, { lineCounter }); + if (!Array.isArray(docs) || docs.some(doc => doc.errors.length > 0)) { + return null; + } + const source = new SourceText(text, lineCounter); + + const doc = pipelineDocument(docs); + if (doc === null) { + return null; + } + // An empty document parses to no contents, or to a null scalar when it follows a `---`. + const contents = doc?.contents; + if (contents === undefined || contents === null || (isScalar(contents) && contents.value === null)) { + return appendNewInclude(source, snippet); + } + if (!isMap(contents)) { + return null; + } + + const include = contents.items.find(pair => isScalar(pair.key) && pair.key.value === 'include'); + if (!include || !isScalar(include.key) || !include.key.range) { + return prependNewInclude(source, contents, snippet); + } + return planIncludeInsertion(source, include.key.range, include.value, snippet); +} + +/** + * The document the `include:` belongs in. A component template opens with a `spec:` header document and keeps its + * pipeline in the next one; anything else is the first document. + * + * @param docs The parsed document stream. + * @returns The pipeline document; `undefined` for an empty or comment-only stream; `null` for a template with a + * `spec:` header but no `---` pipeline document to put the include in. + */ +function pipelineDocument(docs: Document.Parsed[]): Document.Parsed | undefined | null { + const [first, second] = docs; + const hasSpecHeader = isMap(first?.contents) && first.contents.has('spec'); + if (!hasSpecHeader) { + return first; + } + return second ?? null; +} + +/** + * A new `include:` block before the document's first key, so leading comments stay on top. + * + * @param source The document text. + * @param contents The pipeline document's top-level mapping. + * @param snippet The `- component:` entry to add. + * @returns An insertion at the start of the first key's line, or `null` if that key doesn't start its line. + */ +function prependNewInclude(source: SourceText, contents: Node, snippet: string): ComponentInsertion | null { + if (!contents.range) { + return null; + } + const at = source.positionOf(contents.range[0]); + if (at.character !== 0) { + return null; + } + return { start: at, end: at, text: `include:\n${snippet}\n\n` }; +} + +/** + * A new `include:` block at the end of a file with no content to put it before: empty, comments only, or a template + * whose pipeline document is still empty. + * + * @param source The document text. + * @param snippet The `- component:` entry to add. + * @returns An insertion at the end of the file, starting on a new line. + */ +function appendNewInclude(source: SourceText, snippet: string): ComponentInsertion { + const block = `include:\n${snippet}`; + const lastLine = source.lines.length - 1; + const at = source.lineEnd(lastLine); + return { start: at, end: at, text: source.lines[lastLine].trim() === '' ? `${block}\n` : `\n${block}` }; +} + +/** + * Insert into the existing `include:` key, converting a single entry into a list where needed. + * + * @param source The document text. + * @param keyRange The `include` key's source range. + * @param value The key's value node, as parsed. + * @param snippet The `- component:` entry to add. + * @returns The range to replace and its replacement text, or `null` for a value that can't be rewritten. + */ +function planIncludeInsertion( + source: SourceText, + keyRange: Range, + value: unknown, + snippet: string, +): ComponentInsertion | null { + if (!isNode(value) || isAlias(value) || value.anchor || value.tag || !value.range) { + return null; + } + + const valueEnd = source.lineEnd(source.lineOf(value.range[1] - 1)); + + if (isSeq(value) && !value.flow) { + const firstItem = value.items[0]; + if (!isNode(firstItem) || !firstItem.range) { + return null; + } + const indent = /^\s*/.exec(source.lines[source.lineOf(firstItem.range[0])])?.[0] ?? ''; + return { start: valueEnd, end: valueEnd, text: `\n${reindent(snippet, indent)}` }; + } + + if (isMap(value) && !value.flow) { + return convertBlockMapping(source, value.range, valueEnd, snippet); + } + + // A null value (`include:`, `include: ~`, `include: null`) holds no entries; its text is dropped. + const isNull = isScalar(value) && value.value === null; + const entries = isSeq(value) ? value.items : isNull ? [] : [value]; + const items = entries.map(entry => + isNode(entry) && entry.range ? source.text.slice(entry.range[0], entry.range[1]) : null, + ); + if (items.some(item => item === null || item.includes('\n')) || isBlockScalar(value)) { + return null; + } + + const colon = source.text.indexOf(':', keyRange[1]); + const comment = source.lines[valueEnd.line].slice(source.positionOf(value.range[1]).character).trim(); + const list = items.map(item => `\n${SNIPPET_ITEM_INDENT}- ${item}`).join(''); + return { + start: source.positionOf(colon + 1), + end: valueEnd, + text: `${comment ? ` ${comment}` : ''}${list}\n${snippet}`, + }; +} + +/** + * Turn a block mapping holding a single include entry into the first item of a list, at the mapping's own + * indentation, and append the snippet after it. + * + * @param source The document text. + * @param range The mapping's source range. + * @param valueEnd End of the mapping's last line. + * @param snippet The `- component:` entry to add. + * @returns A replacement of the mapping's lines. + */ +function convertBlockMapping( + source: SourceText, + range: Range, + valueEnd: TextPosition, + snippet: string, +): ComponentInsertion { + const firstLine = source.lineOf(range[0]); + const indent = /^\s*/.exec(source.lines[firstLine])?.[0] ?? ''; + const asItem = source.lines + .slice(firstLine, valueEnd.line + 1) + .map((line, i) => (i === 0 ? `${indent}- ${line.slice(indent.length)}` : line.trim() === '' ? line : ` ${line}`)) + .join('\n'); + return { + start: { line: firstLine, character: 0 }, + end: valueEnd, + text: `${asItem}\n${reindent(snippet, indent)}`, + }; +} + +/** + * Re-base the snippet's two-space item indentation onto `indent`. + * + * @param snippet The `- component:` entry, indented as `generateComponentText` emits it. + * @param indent The leading whitespace of the target list's `- ` items. + * @returns The snippet with each line's leading two spaces replaced by `indent`. + */ +function reindent(snippet: string, indent: string): string { + return snippet + .split('\n') + .map(line => (line.startsWith(SNIPPET_ITEM_INDENT) ? indent + line.slice(SNIPPET_ITEM_INDENT.length) : line)) + .join('\n'); +} + +/** Type-guard: a parsed value is a YAML node carrying source information, rather than a bare `Pair` or `null`. */ +function isNode(value: unknown): value is Node { + return isScalar(value) || isMap(value) || isSeq(value) || isAlias(value); +} + +/** Whether `value` is a `|` or `>` block scalar, whose text lives on the lines below its key. */ +function isBlockScalar(value: Node): boolean { + return isScalar(value) && (value.type === Scalar.BLOCK_LITERAL || value.type === Scalar.BLOCK_FOLDED); +} diff --git a/tests/unit/componentInsertionPoint.test.ts b/tests/unit/componentInsertionPoint.test.ts new file mode 100644 index 0000000..b5b3a67 --- /dev/null +++ b/tests/unit/componentInsertionPoint.test.ts @@ -0,0 +1,218 @@ +// @mocha +/** + * Tests src/providers/componentInsertionPoint.ts — where the Component Browser's "Insert" places a new component: + * at the end of the top-level `include:` list, or in a new `include:` block at the top of the file. + */ + +import * as assert from 'node:assert/strict'; +import { + planComponentInsertion, + type ComponentInsertion, + type TextPosition, +} from '../../src/providers/componentInsertionPoint'; + +const SNIPPET = ' - component: gitlab.com/g/p/c@v1'; +const SNIPPET_WITH_INPUTS = ' - component: gitlab.com/g/p/c@v1\n inputs:\n stage: "test" # optional'; + +/** Apply `insertion` to `text` so assertions read as the resulting document. */ +function apply(text: string, insertion: ComponentInsertion | null): string { + assert.ok(insertion, 'expected an insertion plan'); + const lines = text.split('\n'); + const offset = (at: TextPosition) => + lines.slice(0, at.line).reduce((sum, line) => sum + line.length + 1, 0) + at.character; + return text.slice(0, offset(insertion.start)) + insertion.text + text.slice(offset(insertion.end)); +} + +const plannedText = (text: string, snippet = SNIPPET) => apply(text, planComponentInsertion(text, snippet)); + +suite('planComponentInsertion — existing include list', () => { + test('appends after the last entry, before the next top-level key', () => { + assert.strictEqual( + plannedText('include:\n - local: a.yml\n - component: gitlab.com/g/p/other@v2\n\nstages:\n - test\n'), + 'include:\n - local: a.yml\n - component: gitlab.com/g/p/other@v2\n' + + ' - component: gitlab.com/g/p/c@v1\n\nstages:\n - test\n', + ); + }); + + test('appends after a multi-line last entry', () => { + assert.strictEqual( + plannedText('include:\n - component: gitlab.com/g/p/a@v1\n inputs:\n stage: build\n' + + 'build:\n script: make\n'), + 'include:\n - component: gitlab.com/g/p/a@v1\n inputs:\n stage: build\n' + + ' - component: gitlab.com/g/p/c@v1\nbuild:\n script: make\n', + ); + }); + + test('re-indents the snippet to match a column-0 list', () => { + assert.strictEqual( + plannedText('include:\n- local: a.yml\n', SNIPPET_WITH_INPUTS), + 'include:\n- local: a.yml\n- component: gitlab.com/g/p/c@v1\n inputs:\n stage: "test" # optional\n', + ); + }); + + test('re-indents the snippet to match a 4-space list', () => { + assert.strictEqual( + plannedText('include:\n - local: a.yml\n'), + 'include:\n - local: a.yml\n - component: gitlab.com/g/p/c@v1\n', + ); + }); + + test('leaves trailing comments and blank lines after the new entry', () => { + assert.strictEqual( + plannedText('include:\n - local: a.yml\n # - local: disabled.yml\n\n# Stages\nstages: [test]\n'), + 'include:\n - local: a.yml\n - component: gitlab.com/g/p/c@v1\n' + + ' # - local: disabled.yml\n\n# Stages\nstages: [test]\n', + ); + }); + + test('appends to an include list at the end of a file with no trailing newline', () => { + assert.strictEqual( + plannedText('stages: [test]\ninclude:\n - local: a.yml'), + 'stages: [test]\ninclude:\n - local: a.yml\n - component: gitlab.com/g/p/c@v1', + ); + }); + + test('fills an empty include key, keeping its comment', () => { + assert.strictEqual( + plannedText('include: # shared\nstages: [test]\n'), + 'include: # shared\n - component: gitlab.com/g/p/c@v1\nstages: [test]\n', + ); + }); + + for (const nullValue of ['~', 'null', 'Null']) { + test(`replaces an explicit \`include: ${nullValue}\` rather than listing it as an entry`, () => { + assert.strictEqual( + plannedText(`include: ${nullValue}\nstages: [test]\n`), + `include:\n${SNIPPET}\nstages: [test]\n`, + ); + assert.strictEqual( + plannedText(`include: ${nullValue} # shared\nstages: [test]\n`), + `include: # shared\n${SNIPPET}\nstages: [test]\n`, + ); + }); + } + + test('positions the insert before a CRLF line ending', () => { + const at = { line: 1, character: ' - local: a.yml'.length }; + assert.deepStrictEqual( + planComponentInsertion('include:\r\n - local: a.yml\r\n', SNIPPET), + { start: at, end: at, text: `\n${SNIPPET}` }, + ); + }); + + test('ignores an indented include key belonging to a job', () => { + assert.strictEqual(plannedText('job:\n include: nope\n'), `include:\n${SNIPPET}\n\njob:\n include: nope\n`); + }); +}); + +suite('planComponentInsertion — single include entry', () => { + const cases: [string, string, string][] = [ + ['a plain scalar', 'include: a.yml', 'include:\n - a.yml'], + ['a quoted scalar', "include: 'a #1.yml'", "include:\n - 'a #1.yml'"], + [ + 'a flow mapping', + 'include: { local: a.yml, rules: [{ if: $X }] }', + 'include:\n - { local: a.yml, rules: [{ if: $X }] }', + ], + [ + 'a flow sequence', + 'include: [a.yml, "b, c.yml", { local: d.yml }]', + 'include:\n - a.yml\n - "b, c.yml"\n - { local: d.yml }', + ], + ['an empty flow sequence', 'include: []', 'include:'], + ['a value with a trailing comment', 'include: a.yml # shared', 'include: # shared\n - a.yml'], + ]; + for (const [label, line, converted] of cases) { + test(`converts ${label} into a list`, () => { + assert.strictEqual(plannedText(`${line}\nstages: [test]\n`), `${converted}\n${SNIPPET}\nstages: [test]\n`); + }); + } + + test('converts a flow sequence spanning lines, moving its trailing comment to the include line', () => { + assert.strictEqual( + plannedText('include: [a.yml,\n b.yml] # shared\nstages: [test]\n'), + `include: # shared\n - a.yml\n - b.yml\n${SNIPPET}\nstages: [test]\n`, + ); + }); + + test('converts a block mapping into a list, re-indenting its nested lines', () => { + assert.strictEqual( + plannedText('include:\n local: a.yml\n rules:\n - if: $X\n\nstages: [test]\n'), + 'include:\n - local: a.yml\n rules:\n - if: $X\n - component: gitlab.com/g/p/c@v1\n\nstages: [test]\n', + ); + }); + + const unconvertible = [ + 'include: *shared', + 'include: &shared a.yml', + 'include: !reference [.x, include]', + 'include: |\n a.yml', + 'include: [{ local: a.yml,\n rules: [] }]', + ]; + for (const line of unconvertible) { + test(`returns null for \`${line}\` so the caller falls back to the cursor`, () => { + assert.strictEqual(planComponentInsertion(`${line}\nstages: [test]\n`, SNIPPET), null); + }); + } + + test('returns null for a plain scalar continued on the next line', () => { + assert.strictEqual(planComponentInsertion('include: a\n .yml\n', SNIPPET), null); + }); +}); + +suite('planComponentInsertion — unparseable or unplaceable files', () => { + test('returns null for YAML that does not parse', () => { + assert.strictEqual(planComponentInsertion('include: [a.yml,\nstages: [test]\n', SNIPPET), null); + }); + + test('returns null for a spec header with no pipeline document', () => { + assert.strictEqual(planComponentInsertion('spec:\n inputs: {}\n', SNIPPET), null); + }); + + test('still places the include when another key uses a !reference tag', () => { + assert.strictEqual( + plannedText('job:\n script: !reference [.setup, script]\n'), + `include:\n${SNIPPET}\n\njob:\n script: !reference [.setup, script]\n`, + ); + }); +}); + +suite('planComponentInsertion — no include key', () => { + test('adds an include block at the top of the file, separated by a blank line', () => { + assert.strictEqual(plannedText('stages:\n - test\n'), `include:\n${SNIPPET}\n\nstages:\n - test\n`); + }); + + test('adds the include block after leading comments and blank lines', () => { + assert.strictEqual( + plannedText('# Pipeline for X\n# owner: y\n\nstages: [test]\n'), + `# Pipeline for X\n# owner: y\n\ninclude:\n${SNIPPET}\n\nstages: [test]\n`, + ); + }); + + test('adds the include block after a file of only comments', () => { + assert.strictEqual(plannedText('# todo\n'), `# todo\ninclude:\n${SNIPPET}\n`); + assert.strictEqual(plannedText('# todo'), `# todo\ninclude:\n${SNIPPET}`); + }); + + test('adds an include block to an empty file', () => { + assert.strictEqual(plannedText(''), `include:\n${SNIPPET}\n`); + }); + + test('places the include block after a component template\'s spec header', () => { + assert.strictEqual( + plannedText('spec:\n inputs:\n stage:\n default: test\n---\n# Jobs\njob:\n script: make\n'), + `spec:\n inputs:\n stage:\n default: test\n---\n# Jobs\ninclude:\n${SNIPPET}\n\njob:\n script: make\n`, + ); + }); + + test('adds the include block to a component template\'s empty pipeline document', () => { + assert.strictEqual(plannedText('spec:\n inputs: {}\n---\n'), `spec:\n inputs: {}\n---\ninclude:\n${SNIPPET}\n`); + }); + + test('appends to an include list in the pipeline document of a component template', () => { + assert.strictEqual( + plannedText('spec:\n inputs: {}\n---\ninclude:\n - local: a.yml\n'), + `spec:\n inputs: {}\n---\ninclude:\n - local: a.yml\n${SNIPPET}\n`, + ); + }); +});