diff --git a/apps/vscode/src/diagnostics.test.ts b/apps/vscode/src/diagnostics.test.ts new file mode 100644 index 0000000..1946656 --- /dev/null +++ b/apps/vscode/src/diagnostics.test.ts @@ -0,0 +1,82 @@ +import * as vscode from 'vscode'; +import { describe, expect, it } from 'vitest'; +import type { SourceRange } from 'helm'; +import { createFakeDocument } from '../test/fake-document'; +import { diagnosticsFor } from './diagnostics'; +import type { ReferenceCheck, ReferenceVerdict, UncheckedReason } from './reference-check'; + +const REPOSITORY = 'registry.example.com/svc'; +const TAG = '1.0'; +const VALUES_YAML = ['image:', ` repository: ${REPOSITORY}`, ` tag: "${TAG}"`, ''].join('\n'); + +// A record rather than a list, so a new reason fails the build here instead +// of going untested. +const UNCHECKED_REASONS: Record = { + 'no-tag': true, + 'no-registry': true, + 'missing-credential': true, + 'network-error': true, + 'unexpected-response': true, + 'malformed-reference': true, +}; + +/** The source range of `text`'s first occurrence in {@link VALUES_YAML}. */ +function rangeOfText(text: string): SourceRange { + const start = VALUES_YAML.indexOf(text); + + return { start, end: start + text.length }; +} + +/** The document range covering `text`'s first occurrence in {@link VALUES_YAML}. */ +function documentRangeOfText(document: vscode.TextDocument, text: string): vscode.Range { + const { start, end } = rangeOfText(text); + + return new vscode.Range(document.positionAt(start), document.positionAt(end)); +} + +/** A check over the single reference in {@link VALUES_YAML}, carrying that file's real offsets. */ +function createCheck(verdict: ReferenceVerdict): ReferenceCheck { + return { + reference: { + repository: { text: REPOSITORY, range: rangeOfText(REPOSITORY) }, + tag: { text: TAG, range: rangeOfText(TAG) }, + }, + verdict, + }; +} + +describe('diagnostics', () => { + it('should report an error naming the missing repository, positioned on the repository value', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const fileDiagnostics = diagnosticsFor(document, [createCheck({ kind: 'repository-not-found', repository: REPOSITORY })]); + + expect(fileDiagnostics).toHaveLength(1); + expect(fileDiagnostics[0]?.severity).toBe(vscode.DiagnosticSeverity.Error); + expect(fileDiagnostics[0]?.message).toContain(REPOSITORY); + expect(fileDiagnostics[0]?.range).toEqual(documentRangeOfText(document, REPOSITORY)); + }); + + it('should report an error naming the missing tag, positioned on the tag value', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const fileDiagnostics = diagnosticsFor(document, [createCheck({ kind: 'tag-not-found', repository: REPOSITORY, tag: TAG })]); + + expect(fileDiagnostics).toHaveLength(1); + expect(fileDiagnostics[0]?.severity).toBe(vscode.DiagnosticSeverity.Error); + expect(fileDiagnostics[0]?.message).toContain(TAG); + expect(fileDiagnostics[0]?.range).toEqual(documentRangeOfText(document, TAG)); + }); + + it('should report nothing for a reference that exists', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + + expect(diagnosticsFor(document, [createCheck({ kind: 'exists', registry: 'registry.example.com' })])).toEqual([]); + }); + + it('should report nothing for any unverifiable reason, since an unreachable registry is not a missing image', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + + for (const reason of Object.keys(UNCHECKED_REASONS) as UncheckedReason[]) { + expect(diagnosticsFor(document, [createCheck({ kind: 'unverifiable', reason })])).toEqual([]); + } + }); +}); diff --git a/apps/vscode/src/diagnostics.ts b/apps/vscode/src/diagnostics.ts new file mode 100644 index 0000000..43c18fd --- /dev/null +++ b/apps/vscode/src/diagnostics.ts @@ -0,0 +1,39 @@ +import * as vscode from 'vscode'; +import type { ReferenceCheck } from './reference-check'; +import { rangeOf } from './source-range'; + +/** + * The diagnostics a document's checks call for. Only the two not-found + * verdicts qualify: that an unverifiable verdict never renders as an error + * is the one invariant this feature must not break, because an expired token + * or an unreachable registry must never look like a missing image. + */ +function diagnosticsFor(document: vscode.TextDocument, checks: readonly ReferenceCheck[]): vscode.Diagnostic[] { + const fileDiagnostics: vscode.Diagnostic[] = []; + + for (const { reference, verdict } of checks) { + if (verdict.kind === 'repository-not-found') { + fileDiagnostics.push( + new vscode.Diagnostic( + rangeOf(document, reference.repository.range), + `Repository '${verdict.repository}' not found.`, + vscode.DiagnosticSeverity.Error + ) + ); + } else if (verdict.kind === 'tag-not-found') { + fileDiagnostics.push( + new vscode.Diagnostic( + // Unreachable fallback: only a reference that named a tag can come + // back tag-not-found. It keeps the type honest without an assertion. + rangeOf(document, reference.tag?.range ?? reference.repository.range), + `Tag '${verdict.tag}' not found in '${verdict.repository}'.`, + vscode.DiagnosticSeverity.Error + ) + ); + } + } + + return fileDiagnostics; +} + +export { diagnosticsFor }; diff --git a/apps/vscode/src/extension.test.ts b/apps/vscode/src/extension.test.ts index b8bc9ff..1f1e3c4 100644 --- a/apps/vscode/src/extension.test.ts +++ b/apps/vscode/src/extension.test.ts @@ -1,35 +1,47 @@ import * as vscode from 'vscode'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { emitDidOpenTextDocument, getLastDiagnosticCollection } from '../test/vscode-stub'; +import { bumpVersion, createFakeDocument } from '../test/fake-document'; +import { fakeFetchResponse } from '../test/fake-fetch'; +import { + createTextEditorStub, + emitDidChangeVisibleTextEditors, + emitDidOpenTextDocument, + getLastDiagnosticCollection, + getRegisteredHoverProvider, + setVisibleTextEditors, + type TextEditorStub, +} from '../test/vscode-stub'; import { activate, deactivate } from './extension'; -/** Builds a fake `vscode.TextDocument`, with a real `positionAt` so range assertions are exact. */ -function createFakeDocument(path: string, text: string, languageId = 'yaml'): vscode.TextDocument { - return { - uri: { path, toString: () => path }, - languageId, - getText: () => text, - positionAt: (offset: number) => { - const before = text.slice(0, offset); - const lines = before.split('\n'); - const line = lines.length - 1; - const character = lines[lines.length - 1]?.length ?? 0; - - return new vscode.Position(line, character); - }, - } as unknown as vscode.TextDocument; +const REPOSITORY = 'docker.io/library/nginx'; +const TAG = '1.19'; +const VALUES_YAML = ['image:', ` repository: ${REPOSITORY}`, ` tag: ${TAG}`, ''].join('\n'); + +/** Stages `document` as the only visible editor, then fires the open event. */ +async function openInVisibleEditor(document: vscode.TextDocument): Promise { + const editor = createTextEditorStub(document); + + setVisibleTextEditors([editor]); + await emitDidOpenTextDocument(document); + + return editor; } -const VALUES_YAML = ['image:', ' repository: docker.io/library/nginx', ' tag: 1.19', ''].join('\n'); +/** The decoration options an editor's most recent `setDecorations` call carried. */ +function getLastDecorations(editor: TextEditorStub): vscode.DecorationOptions[] { + const { calls } = editor.setDecorations.mock; + const lastCall = calls[calls.length - 1] as [unknown, vscode.DecorationOptions[]] | undefined; + + if (lastCall === undefined) { + throw new Error('expected setDecorations to have been called'); + } + + return lastCall[1]; +} -/** A canned fetch `Response`-shaped object for the injected fetch fake. */ -function fakeFetchResponse(status: number, body: unknown = {}): { status: number; ok: boolean; json: () => Promise } { - return { - status, - ok: status >= 200 && status < 300, - // eslint-disable-next-line @typescript-eslint/promise-function-async -- trivial canned response, nothing to await - json: () => Promise.resolve(body), - }; +/** Asks the registered hover provider for a hover at `offset` in `document`. */ +function hoverAt(document: vscode.TextDocument, offset: number): vscode.Hover | undefined { + return getRegisteredHoverProvider()?.provideHover(document, document.positionAt(offset)) as vscode.Hover | undefined; } /** Asserts a diagnostics `.set()` call carried exactly one diagnostic, and returns it. */ @@ -61,6 +73,8 @@ describe('extension', () => { for (const subscription of context.subscriptions) { subscription.dispose(); } + + setVisibleTextEditors([]); }); it('should create an output channel and register it for disposal on activate', () => { @@ -74,84 +88,83 @@ describe('extension', () => { expect(() => deactivate()).not.toThrow(); }); - it('should set no diagnostics when the referenced image exists', async () => { - const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200)); - activate(context, { fetch }); + it('should register a yaml hover provider on activate', () => { + activate(context, { fetch: vi.fn() }); - const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); - await emitDidOpenTextDocument(document); + expect(vscode.languages.registerHoverProvider).toHaveBeenCalledWith({ language: 'yaml' }, expect.anything()); + }); - const collection = getLastDiagnosticCollection(); + it('should create one mark decoration type on activate', () => { + activate(context, { fetch: vi.fn() }); - expect(collection?.set).toHaveBeenCalledWith(document.uri, []); + expect(vscode.window.createTextEditorDecorationType).toHaveBeenCalledWith({ after: { margin: '0 0 0 0.5em' } }); }); - it('should set an error diagnostic naming the missing tag, positioned on the tag value, when the tag does not exist', async () => { + it('should set both diagnostics and marks when a values file opens', async () => { const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(404, { errors: [{ code: 'MANIFEST_UNKNOWN' }] })); activate(context, { fetch }); const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); - await emitDidOpenTextDocument(document); - - const collection = getLastDiagnosticCollection(); - const diagnostic = getSingleDiagnostic(collection?.set.mock.calls[0]?.[1] as vscode.Diagnostic[] | undefined); + const editor = await openInVisibleEditor(document); + const diagnostic = getSingleDiagnostic(getLastDiagnosticCollection()?.set.mock.calls[0]?.[1] as vscode.Diagnostic[] | undefined); expect(diagnostic.severity).toBe(vscode.DiagnosticSeverity.Error); - expect(diagnostic.message).toContain('1.19'); - - const tagStart = VALUES_YAML.indexOf('1.19'); - - expect(diagnostic.range).toEqual(new vscode.Range(document.positionAt(tagStart), document.positionAt(tagStart + '1.19'.length))); + expect(diagnostic.message).toContain(TAG); + expect(getLastDecorations(editor)[0]?.renderOptions?.after?.contentText).toBe(' ✗'); }); - it('should set an error diagnostic naming the missing repository when the repository does not exist', async () => { - const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(404, { errors: [{ code: 'NAME_UNKNOWN' }] })); + it('should hover a checked reference, and stop once the document has been edited past the check', async () => { + const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200)); activate(context, { fetch }); const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); - await emitDidOpenTextDocument(document); - - const collection = getLastDiagnosticCollection(); - const diagnostic = getSingleDiagnostic(collection?.set.mock.calls[0]?.[1] as vscode.Diagnostic[] | undefined); + await openInVisibleEditor(document); - expect(diagnostic.severity).toBe(vscode.DiagnosticSeverity.Error); - expect(diagnostic.message).toContain('docker.io/library/nginx'); + expect(hoverAt(document, VALUES_YAML.indexOf(REPOSITORY))).toBeDefined(); - const repositoryStart = VALUES_YAML.indexOf('docker.io/library/nginx'); + bumpVersion(document); - expect(diagnostic.range).toEqual( - new vscode.Range(document.positionAt(repositoryStart), document.positionAt(repositoryStart + 'docker.io/library/nginx'.length)) - ); + expect(hoverAt(document, VALUES_YAML.indexOf(REPOSITORY))).toBeUndefined(); }); - it('should set no diagnostics when the registry is unreachable, since unverifiable never renders as an error', async () => { - const fetch = vi.fn().mockRejectedValue(new Error('getaddrinfo ENOTFOUND')); + it('should re-apply marks to an editor that becomes visible after the document was checked', async () => { + const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200)); activate(context, { fetch }); const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + + setVisibleTextEditors([]); await emitDidOpenTextDocument(document); - const collection = getLastDiagnosticCollection(); + const editor = createTextEditorStub(document); + await emitDidChangeVisibleTextEditors([editor]); - expect(collection?.set).toHaveBeenCalledWith(document.uri, []); + expect(getLastDecorations(editor)[0]?.renderOptions?.after?.contentText).toBe(' ✓'); }); - it('should ignore a document that is not the conventional values file name', async () => { - const fetch = vi.fn(); + it('should survive an editor disposed mid-check, since an unhandled rejection kills the extension host', async () => { + const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200)); activate(context, { fetch }); - const document = createFakeDocument('/repo/chart/deployment.yaml', VALUES_YAML); - await emitDidOpenTextDocument(document); + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const disposedEditor: TextEditorStub = { + document, + setDecorations: vi.fn(() => { + throw new Error('TextEditor#setDecorations: editor disposed'); + }), + }; - expect(fetch).not.toHaveBeenCalled(); + setVisibleTextEditors([disposedEditor]); + + await expect(emitDidOpenTextDocument(document)).resolves.toBeUndefined(); + expect(getLastDiagnosticCollection()?.set).toHaveBeenCalledWith(document.uri, []); }); - it('should ignore a document whose language is not yaml', async () => { + it('should issue no request for a document that is not a values file', async () => { const fetch = vi.fn(); activate(context, { fetch }); - const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML, 'plaintext'); - await emitDidOpenTextDocument(document); + await emitDidOpenTextDocument(createFakeDocument('/repo/chart/deployment.yaml', VALUES_YAML)); expect(fetch).not.toHaveBeenCalled(); }); diff --git a/apps/vscode/src/extension.ts b/apps/vscode/src/extension.ts index 4ebb40d..b2ab114 100644 --- a/apps/vscode/src/extension.ts +++ b/apps/vscode/src/extension.ts @@ -1,27 +1,20 @@ import * as vscode from 'vscode'; -import { extractImageReferences, type ImageReference, type SourceRange } from 'helm'; -import { checkImageExistence, type FetchLike } from 'oci-registry'; +import type { FetchLike } from 'oci-registry'; +import { diagnosticsFor } from './diagnostics'; +import { hoverFor } from './hover'; +import { applyMarks, createMarkDecorationType } from './marks'; +import { checkImageReferencesInDocument, checksAsOf, type DocumentChecks } from './reference-check'; const DIAGNOSTIC_COLLECTION_NAME = 'infra-tools-images'; -// The conventional Helm values file name only. Matching any YAML file -// beneath a chart directory (and excluding its templates directory) is -// Helm chart-context knowledge this ticket doesn't implement yet. -const VALUES_FILE_NAME_PATTERN = /^values\.ya?ml$/i; - interface ActivateDependencies { - /** - * The fetch implementation existence checks use. Defaults to the - * platform's global `fetch`; only tests have a reason to override it — - * production activation never does. - */ + /** Only tests override this; production activation uses the platform's `fetch`. */ readonly fetch?: FetchLike; } /** - * Called by the extension host when the extension activates. Registers a - * diagnostics collection and checks a Helm values file's image references - * against their registries whenever one is opened. + * Called by the extension host on activation. Wires the three surfaces a + * check is shown on and re-checks a Helm values file whenever one opens. */ function activate(context: vscode.ExtensionContext, dependencies: ActivateDependencies = {}): void { const channel = vscode.window.createOutputChannel('Infra Tools'); @@ -32,100 +25,54 @@ function activate(context: vscode.ExtensionContext, dependencies: ActivateDepend const diagnostics = vscode.languages.createDiagnosticCollection(DIAGNOSTIC_COLLECTION_NAME); context.subscriptions.push(diagnostics); + const checksByDocument = new Map(); + const markDecorationType = createMarkDecorationType(); + context.subscriptions.push(markDecorationType); + context.subscriptions.push( vscode.workspace.onDidOpenTextDocument(async (document) => { - await checkImageReferencesInDocument(document, diagnostics, fetchImpl); + // VS Code never awaits a listener, so anything escaping this callback + // is an unhandled rejection, and that takes the extension host down. + try { + const checked = await checkImageReferencesInDocument(document, fetchImpl); + + if (checked === undefined) { + return; + } + + checksByDocument.set(document.uri.toString(), checked); + diagnostics.set(document.uri, diagnosticsFor(document, checksAsOf(checked, document))); + applyMarks(vscode.window.visibleTextEditors, checksByDocument, markDecorationType); + } catch (error) { + channel.appendLine(`Checking image references in ${document.uri.toString()} failed: ${String(error)}`); + } }) ); -} - -function deactivate(): void { - // Nothing to clean up yet. -} - -/** Whether a document is the conventional Helm values file. */ -function isHelmValuesFile(document: vscode.TextDocument): boolean { - if (document.languageId !== 'yaml') { - return false; - } - - const fileName = document.uri.path.split('/').pop() ?? ''; - return VALUES_FILE_NAME_PATTERN.test(fileName); -} - -/** - * Extracts and checks a document's image references, then replaces its - * diagnostics with the result. Runs on document open only — checking again - * as the developer types, and clearing stale results when a chart's - * `appVersion` changes, are later tickets. - */ -async function checkImageReferencesInDocument( - document: vscode.TextDocument, - diagnostics: vscode.DiagnosticCollection, - fetch: FetchLike -): Promise { - if (!isHelmValuesFile(document)) { - return; - } - - let references: ImageReference[]; - try { - references = extractImageReferences(document.getText()); - } catch { - // A YAML syntax error is the YAML language service's diagnostic to - // raise, not this feature's — stay silent rather than compete with it. - return; - } - - // A tagless reference resolves through `appVersion`, a later ticket's job — - // nothing to check yet. - const taggedReferences = references.filter( - (reference): reference is ImageReference & { tag: NonNullable } => reference.tag !== undefined + context.subscriptions.push( + vscode.languages.registerHoverProvider( + { language: 'yaml' }, + { + provideHover(document: vscode.TextDocument, position: vscode.Position): vscode.Hover | undefined { + const checked = checksByDocument.get(document.uri.toString()); + + return checked === undefined ? undefined : hoverFor(document, position, checksAsOf(checked, document)); + }, + } + ) ); - const checks = await Promise.all( - taggedReferences.map(async (reference) => ({ - reference, - verdict: await checkImageExistence({ - repository: reference.repository.text, - tag: reference.tag.text, - fetch, - }), - })) + // A document can be checked before its editor is visible, and tab switches + // hand out editors carrying no decorations yet. + context.subscriptions.push( + vscode.window.onDidChangeVisibleTextEditors((editors) => { + applyMarks(editors, checksByDocument, markDecorationType); + }) ); - - const fileDiagnostics: vscode.Diagnostic[] = []; - - for (const { reference, verdict } of checks) { - // 'exists' and 'unverifiable' both produce no diagnostic. That an - // unverifiable verdict never renders as an error is the one invariant - // this feature must never break — an expired token or an unreachable - // registry must never look like a missing image. - if (verdict.kind === 'repository-not-found') { - fileDiagnostics.push( - new vscode.Diagnostic( - rangeOf(document, reference.repository.range), - `Repository '${verdict.repository}' not found.`, - vscode.DiagnosticSeverity.Error - ) - ); - } else if (verdict.kind === 'tag-not-found') { - fileDiagnostics.push( - new vscode.Diagnostic( - rangeOf(document, reference.tag.range), - `Tag '${verdict.tag}' not found in '${verdict.repository}'.`, - vscode.DiagnosticSeverity.Error - ) - ); - } - } - - diagnostics.set(document.uri, fileDiagnostics); } -function rangeOf(document: vscode.TextDocument, range: SourceRange): vscode.Range { - return new vscode.Range(document.positionAt(range.start), document.positionAt(range.end)); +function deactivate(): void { + // Nothing to clean up yet. } export { activate, deactivate }; diff --git a/apps/vscode/src/hover.test.ts b/apps/vscode/src/hover.test.ts new file mode 100644 index 0000000..3d97057 --- /dev/null +++ b/apps/vscode/src/hover.test.ts @@ -0,0 +1,115 @@ +import type * as vscode from 'vscode'; +import { describe, expect, it } from 'vitest'; +import type { SourceRange } from 'helm'; +import { createFakeDocument } from '../test/fake-document'; +import { hoverFor } from './hover'; +import type { ReferenceCheck, ReferenceVerdict, UncheckedReason } from './reference-check'; + +const REPOSITORY = 'registry.example.com/svc'; +const TAG = '1.0'; +const VALUES_YAML = ['image:', ` repository: ${REPOSITORY}`, ` tag: "${TAG}"`, ''].join('\n'); + +const VERIFIED_VERDICT: ReferenceVerdict = { kind: 'exists', registry: 'mirror.example.com' }; + +// The sentence each reason owes the reader, as a record so a new reason fails +// the build here instead of hovering with someone else's explanation. +const UNCHECKED_REASON_SENTENCES: Record = { + 'no-tag': 'the reference names no tag to check.', + 'no-registry': 'the repository names no registry host.', + 'missing-credential': 'the registry requires credentials this extension cannot supply yet.', + 'network-error': 'the registry could not be reached.', + 'unexpected-response': 'the registry answered in a form this extension does not understand.', + 'malformed-reference': 'the tag is not a valid OCI tag.', +}; + +/** The source range of `text`'s first occurrence in {@link VALUES_YAML}. */ +function rangeOfText(text: string): SourceRange { + const start = VALUES_YAML.indexOf(text); + + return { start, end: start + text.length }; +} + +/** A check over the single reference in {@link VALUES_YAML}, carrying that file's real offsets. */ +function createCheck(verdict: ReferenceVerdict): ReferenceCheck { + return { + reference: { + repository: { text: REPOSITORY, range: rangeOfText(REPOSITORY) }, + tag: { text: TAG, range: rangeOfText(TAG) }, + }, + verdict, + }; +} + +/** A check over the same reference written without a tag, which is what a `no-tag` verdict comes from. */ +function createTaglessCheck(verdict: ReferenceVerdict): ReferenceCheck { + return { + reference: { + repository: { text: REPOSITORY, range: rangeOfText(REPOSITORY) }, + tag: undefined, + }, + verdict, + }; +} + +/** The hover at the first character of `text`'s occurrence in {@link VALUES_YAML}. */ +function hoverAtText(document: vscode.TextDocument, checks: readonly ReferenceCheck[], text: string): vscode.Hover | undefined { + return hoverFor(document, document.positionAt(VALUES_YAML.indexOf(text)), checks); +} + +/** The plain text of a hover's single content entry. */ +function getHoverText(hover: vscode.Hover | undefined): string { + const [content] = hover?.contents ?? []; + + if (content === undefined || typeof content === 'string') { + throw new Error('expected a hover carrying one MarkdownString'); + } + + return content.value; +} + +describe('hover', () => { + it('should report the registry that answered for a verified reference', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const hoverText = getHoverText(hoverAtText(document, [createCheck(VERIFIED_VERDICT)], REPOSITORY)); + + expect(hoverText).toContain('Verified'); + expect(hoverText).toContain('mirror.example.com'); + }); + + it('should render its own sentence for every reason a reference went unverified', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + + for (const [reason, sentence] of Object.entries(UNCHECKED_REASON_SENTENCES) as [UncheckedReason, string][]) { + const hoverText = getHoverText(hoverAtText(document, [createCheck({ kind: 'unverifiable', reason })], REPOSITORY)); + + expect(hoverText).toBe(`Not verified: ${sentence}`); + } + }); + + it('should explain a tagless reference, which carries no tag to hover in the first place', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const check = createTaglessCheck({ kind: 'unverifiable', reason: 'no-tag' }); + + expect(getHoverText(hoverAtText(document, [check], REPOSITORY))).toContain('no tag'); + }); + + it('should provide no hover for a reference that does not exist, which already speaks through its diagnostic', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + + expect(hoverAtText(document, [createCheck({ kind: 'repository-not-found', repository: REPOSITORY })], REPOSITORY)).toBeUndefined(); + expect(hoverAtText(document, [createCheck({ kind: 'tag-not-found', repository: REPOSITORY, tag: TAG })], TAG)).toBeUndefined(); + }); + + it('should provide no hover outside any checked reference', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + + expect(hoverAtText(document, [createCheck(VERIFIED_VERDICT)], 'image:')).toBeUndefined(); + }); + + it('should resolve a position inside the tag to the same reference as one inside the repository', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const checks = [createCheck(VERIFIED_VERDICT)]; + + expect(getHoverText(hoverAtText(document, checks, TAG))).toBe(getHoverText(hoverAtText(document, checks, REPOSITORY))); + }); +}); diff --git a/apps/vscode/src/hover.ts b/apps/vscode/src/hover.ts new file mode 100644 index 0000000..1a354a7 --- /dev/null +++ b/apps/vscode/src/hover.ts @@ -0,0 +1,44 @@ +import * as vscode from 'vscode'; +import type { ReferenceCheck, UncheckedReason } from './reference-check'; +import { containsOffset } from './source-range'; + +// A table rather than a switch, so adding a reason fails the build here +// instead of hovering with no explanation. +const UNCHECKED_REASON_TEXT: Record = { + 'no-tag': 'the reference names no tag to check.', + 'no-registry': 'the repository names no registry host.', + 'missing-credential': 'the registry requires credentials this extension cannot supply yet.', + 'network-error': 'the registry could not be reached.', + 'unexpected-response': 'the registry answered in a form this extension does not understand.', + 'malformed-reference': 'the tag is not a valid OCI tag.', +}; + +/** + * What hovering a checked reference reports. A not-found verdict gets no + * hover: it already speaks through its diagnostic, and it names no registry. + */ +function hoverFor(document: vscode.TextDocument, position: vscode.Position, checks: readonly ReferenceCheck[]): vscode.Hover | undefined { + const offset = document.offsetAt(position); + const check = checks.find( + ({ reference }) => + containsOffset(reference.repository.range, offset) || (reference.tag !== undefined && containsOffset(reference.tag.range, offset)) + ); + + if (check === undefined) { + return undefined; + } + + const { verdict } = check; + + if (verdict.kind === 'exists') { + return new vscode.Hover(new vscode.MarkdownString(`Verified on \`${verdict.registry}\`.`)); + } + + if (verdict.kind === 'unverifiable') { + return new vscode.Hover(new vscode.MarkdownString(`Not verified: ${UNCHECKED_REASON_TEXT[verdict.reason]}`)); + } + + return undefined; +} + +export { hoverFor }; diff --git a/apps/vscode/src/marks.test.ts b/apps/vscode/src/marks.test.ts new file mode 100644 index 0000000..6ece276 --- /dev/null +++ b/apps/vscode/src/marks.test.ts @@ -0,0 +1,123 @@ +import * as vscode from 'vscode'; +import { describe, expect, it, vi } from 'vitest'; +import type { SourceRange } from 'helm'; +import { bumpVersion, createFakeDocument } from '../test/fake-document'; +import { createTextEditorStub, type TextEditorStub } from '../test/vscode-stub'; +import { applyMarks, createMarkDecorationType, marksFor } from './marks'; +import type { DocumentChecks, ReferenceCheck, ReferenceVerdict } from './reference-check'; + +const REPOSITORY = 'registry.example.com/svc'; +const TAG = '1.0'; +const VALUES_YAML = ['image:', ` repository: ${REPOSITORY}`, ` tag: "${TAG}"`, ''].join('\n'); + +const VERIFIED_VERDICT: ReferenceVerdict = { kind: 'exists', registry: 'registry.example.com' }; +const MISSING_VERDICT: ReferenceVerdict = { kind: 'tag-not-found', repository: REPOSITORY, tag: TAG }; +const UNCHECKED_VERDICT: ReferenceVerdict = { kind: 'unverifiable', reason: 'network-error' }; + +/** The source range of `text`'s first occurrence in {@link VALUES_YAML}. */ +function rangeOfText(text: string): SourceRange { + const start = VALUES_YAML.indexOf(text); + + return { start, end: start + text.length }; +} + +/** A check over the single reference in {@link VALUES_YAML}, carrying that file's real offsets. */ +function createCheck(verdict: ReferenceVerdict): ReferenceCheck { + return { + reference: { + repository: { text: REPOSITORY, range: rangeOfText(REPOSITORY) }, + tag: { text: TAG, range: rangeOfText(TAG) }, + }, + verdict, + }; +} + +/** The stub editors, as the editor list `applyMarks` takes. */ +function asEditors(editors: readonly TextEditorStub[]): readonly vscode.TextEditor[] { + return editors as unknown as readonly vscode.TextEditor[]; +} + +/** One document's stored checks, tagged with the version it currently has. */ +function createChecksByDocument(document: vscode.TextDocument, checks: readonly ReferenceCheck[]): Map { + return new Map([[document.uri.toString(), { version: document.version, checks }]]); +} + +/** An editor that throws the way a disposed one does. */ +function createDisposedEditorStub(document: vscode.TextDocument): TextEditorStub { + return { + document, + setDecorations: vi.fn(() => { + throw new Error('TextEditor#setDecorations: editor disposed'); + }), + }; +} + +describe('marks', () => { + it('should produce one mark per check, whatever each outcome was', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const checks = [createCheck(VERIFIED_VERDICT), createCheck(MISSING_VERDICT), createCheck(UNCHECKED_VERDICT)]; + + expect(marksFor(document, checks)).toHaveLength(checks.length); + }); + + it('should render its own glyph and colour for each of verified, missing and unchecked', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const marks = marksFor(document, [createCheck(VERIFIED_VERDICT), createCheck(MISSING_VERDICT), createCheck(UNCHECKED_VERDICT)]); + + expect(marks.map((mark) => mark.renderOptions?.after?.contentText)).toEqual([' ✓', ' ✗', ' ?']); + expect(marks.map((mark) => mark.renderOptions?.after?.color)).toEqual([ + new vscode.ThemeColor('charts.green'), + new vscode.ThemeColor('errorForeground'), + new vscode.ThemeColor('descriptionForeground'), + ]); + }); + + it('should name the answering registry only when it differs from the host the file names', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const [namedHost] = marksFor(document, [createCheck(VERIFIED_VERDICT)]); + const [otherHost] = marksFor(document, [createCheck({ kind: 'exists', registry: 'mirror.example.com' })]); + + expect(namedHost?.renderOptions?.after?.contentText).toBe(' ✓'); + expect(otherHost?.renderOptions?.after?.contentText).toBe(' ✓ mirror.example.com'); + }); + + it('should place the mark on the repository value', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const [mark] = marksFor(document, [createCheck(VERIFIED_VERDICT)]); + const { start, end } = rangeOfText(REPOSITORY); + + expect(mark?.range).toEqual(new vscode.Range(document.positionAt(start), document.positionAt(end))); + }); + + it('should leave an editor whose document has no stored checks alone', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const editor = createTextEditorStub(document); + + applyMarks(asEditors([editor]), new Map(), createMarkDecorationType()); + + expect(editor.setDecorations).not.toHaveBeenCalled(); + }); + + it('should still decorate the surviving editors when one of them was disposed', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const checks = [createCheck(VERIFIED_VERDICT)]; + const decorationType = createMarkDecorationType(); + const survivingEditor = createTextEditorStub(document); + + applyMarks(asEditors([createDisposedEditorStub(document), survivingEditor]), createChecksByDocument(document, checks), decorationType); + + expect(survivingEditor.setDecorations).toHaveBeenCalledWith(decorationType, marksFor(document, checks)); + }); + + it('should drop marks rather than re-project them onto text edited since the check', () => { + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const checksByDocument = createChecksByDocument(document, [createCheck(VERIFIED_VERDICT)]); + const decorationType = createMarkDecorationType(); + const editor = createTextEditorStub(document); + + bumpVersion(document); + applyMarks(asEditors([editor]), checksByDocument, decorationType); + + expect(editor.setDecorations).toHaveBeenCalledWith(decorationType, []); + }); +}); diff --git a/apps/vscode/src/marks.ts b/apps/vscode/src/marks.ts new file mode 100644 index 0000000..9499fcc --- /dev/null +++ b/apps/vscode/src/marks.ts @@ -0,0 +1,96 @@ +import * as vscode from 'vscode'; +import type { ImageReference } from 'helm'; +import { resolveExplicitHost } from 'oci-registry'; +import { checksAsOf, type DocumentChecks, type ReferenceCheck, type ReferenceVerdict } from './reference-check'; +import { rangeOf } from './source-range'; + +type MarkKind = 'verified' | 'missing' | 'unchecked'; + +// `unchecked` is a muted question mark rather than a cross on purpose: it +// reports a fact about the developer's machine, not a defect in the file, +// and styling it as a failure is how a linter earns being switched off. +const MARKS: Record = { + verified: { glyph: '✓', color: 'charts.green' }, + missing: { glyph: '✗', color: 'errorForeground' }, + unchecked: { glyph: '?', color: 'descriptionForeground' }, +}; + +/** Exhaustive, so a new verdict kind fails the build here. */ +function markKindOf(verdict: ReferenceVerdict): MarkKind { + switch (verdict.kind) { + case 'exists': + return 'verified'; + case 'repository-not-found': + case 'tag-not-found': + return 'missing'; + case 'unverifiable': + return 'unchecked'; + } +} + +/** + * The answering registry, named only when it differs from the host the file + * names, so the mark carries information instead of restating the line. + */ +function registrySuffixOf(reference: ImageReference, verdict: ReferenceVerdict): string { + if (verdict.kind !== 'exists' || verdict.registry === resolveExplicitHost(reference.repository.text)?.host) { + return ''; + } + + return ` ${verdict.registry}`; +} + +/** + * One mark per checked reference, whatever the outcome. Glyph and colour + * both ride on the decoration rather than the type, so all three marks share + * one type and one `setDecorations` call per editor replaces the lot. + */ +function marksFor(document: vscode.TextDocument, checks: readonly ReferenceCheck[]): vscode.DecorationOptions[] { + const decorations: vscode.DecorationOptions[] = []; + + for (const { reference, verdict } of checks) { + const { glyph, color } = MARKS[markKindOf(verdict)]; + + decorations.push({ + range: rangeOf(document, reference.repository.range), + renderOptions: { after: { contentText: ` ${glyph}${registrySuffixOf(reference, verdict)}`, color: new vscode.ThemeColor(color) } }, + }); + } + + return decorations; +} + +function createMarkDecorationType(): vscode.TextEditorDecorationType { + return vscode.window.createTextEditorDecorationType({ after: { margin: '0 0 0 0.5em' } }); +} + +/** + * Re-applies each editor's stored marks. An editor showing a checked + * document always gets a `setDecorations` call, empty array included, so a + * reference whose outcome changed loses the mark it used to have. + */ +function applyMarks( + editors: readonly vscode.TextEditor[], + checksByDocument: ReadonlyMap, + decorationType: vscode.TextEditorDecorationType +): void { + for (const editor of editors) { + const checked = checksByDocument.get(editor.document.uri.toString()); + + if (checked === undefined) { + continue; + } + + try { + editor.setDecorations(decorationType, marksFor(editor.document, checksAsOf(checked, editor.document))); + } catch { + // This runs while editors are being torn down, and `setDecorations` + // throws on a disposed one. One dead editor must not cost every other + // visible editor its marks. + continue; + } + } +} + +export { applyMarks, createMarkDecorationType, marksFor }; +export type { MarkKind }; diff --git a/apps/vscode/src/reference-check.test.ts b/apps/vscode/src/reference-check.test.ts new file mode 100644 index 0000000..93ef51a --- /dev/null +++ b/apps/vscode/src/reference-check.test.ts @@ -0,0 +1,99 @@ +import type * as vscode from 'vscode'; +import { describe, expect, it, vi } from 'vitest'; +import type { FetchLike } from 'oci-registry'; +import { bumpVersion, createFakeDocument } from '../test/fake-document'; +import { fakeFetchResponse } from '../test/fake-fetch'; +import { checkImageReferencesInDocument, checksAsOf, isHelmValuesFile, type DocumentChecks } from './reference-check'; + +const REPOSITORY = 'docker.io/library/nginx'; +const TAG = '1.19'; +const VALUES_YAML = ['image:', ` repository: ${REPOSITORY}`, ` tag: ${TAG}`, ''].join('\n'); +const TAGLESS_VALUES_YAML = ['image:', ' repository: registry.example.com/svc', ' pullPolicy: IfNotPresent', ''].join('\n'); + +/** Checks a document this feature is expected to have something to say about. */ +async function checkValuesFile(document: vscode.TextDocument, fetch: FetchLike): Promise { + const checked = await checkImageReferencesInDocument(document, fetch); + + if (checked === undefined) { + throw new Error('expected the document to be checked'); + } + + return checked; +} + +describe('reference-check', () => { + it('should ignore a document that is not the conventional values file name', async () => { + const fetch = vi.fn(); + const document = createFakeDocument('/repo/chart/deployment.yaml', VALUES_YAML); + + expect(isHelmValuesFile(document)).toBe(false); + await expect(checkImageReferencesInDocument(document, fetch)).resolves.toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should ignore a document whose language is not yaml', async () => { + const fetch = vi.fn(); + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML, 'plaintext'); + + expect(isHelmValuesFile(document)).toBe(false); + await expect(checkImageReferencesInDocument(document, fetch)).resolves.toBeUndefined(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return no checks for a values file whose YAML does not parse', async () => { + const fetch = vi.fn(); + const document = createFakeDocument('/repo/chart/values.yaml', ['image:', ' repository: [unclosed', ''].join('\n')); + + // The extractor collects YAML errors instead of throwing, so a malformed + // file yields zero references rather than the `undefined` that means this + // feature has nothing to say about the document. + await expect(checkValuesFile(document, fetch)).resolves.toEqual({ version: document.version, checks: [] }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should ask no registry about a tagless reference, and report it as unverifiable', async () => { + const fetch = vi.fn(); + const document = createFakeDocument('/repo/chart/values.yaml', TAGLESS_VALUES_YAML); + + const { checks } = await checkValuesFile(document, fetch); + + expect(checks).toHaveLength(1); + expect(checks[0]?.verdict).toEqual({ kind: 'unverifiable', reason: 'no-tag' }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('should return the verdict the registry answer implies for a tagged reference', async () => { + const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200)); + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + + const { checks } = await checkValuesFile(document, fetch); + + expect(checks).toHaveLength(1); + expect(checks[0]?.reference.repository.text).toBe(REPOSITORY); + expect(checks[0]?.verdict).toEqual({ kind: 'exists', registry: 'docker.io' }); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('should tag the checks with the version of the document they describe', async () => { + const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200)); + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + + bumpVersion(document); + + const { version } = await checkValuesFile(document, fetch); + + expect(version).toBe(document.version); + }); + + it('should hand back the checks at the version they describe, and none once the document has moved on', async () => { + const fetch = vi.fn().mockResolvedValue(fakeFetchResponse(200)); + const document = createFakeDocument('/repo/chart/values.yaml', VALUES_YAML); + const checked = await checkValuesFile(document, fetch); + + expect(checksAsOf(checked, document)).toEqual(checked.checks); + + bumpVersion(document); + + expect(checksAsOf(checked, document)).toEqual([]); + }); +}); diff --git a/apps/vscode/src/reference-check.ts b/apps/vscode/src/reference-check.ts new file mode 100644 index 0000000..17cb046 --- /dev/null +++ b/apps/vscode/src/reference-check.ts @@ -0,0 +1,84 @@ +import type * as vscode from 'vscode'; +import { extractImageReferences, type ImageReference } from 'helm'; +import { checkImageExistence, type FetchLike, type ImageVerdict, type UnverifiableReason } from 'oci-registry'; + +// Matching any YAML file beneath a chart directory, and excluding that +// chart's templates directory, is Helm chart-context knowledge this ticket +// doesn't implement yet. +const VALUES_FILE_NAME_PATTERN = /^values\.ya?ml$/i; + +/** Every reason the registry package reports, plus the ones settled before asking it. */ +type UncheckedReason = UnverifiableReason | 'no-tag'; + +type ReferenceVerdict = Exclude | { readonly kind: 'unverifiable'; readonly reason: UncheckedReason }; + +/** + * One checked reference, projected onto all three surfaces, so diagnostics, + * marks, and hovers can never disagree about a reference. + */ +interface ReferenceCheck { + readonly reference: ImageReference; + readonly verdict: ReferenceVerdict; +} + +/** A document's checks, tagged with the document version they describe. */ +interface DocumentChecks { + readonly version: number; + readonly checks: readonly ReferenceCheck[]; +} + +function isHelmValuesFile(document: vscode.TextDocument): boolean { + if (document.languageId !== 'yaml') { + return false; + } + + const fileName = document.uri.path.split('/').pop() ?? ''; + + return VALUES_FILE_NAME_PATTERN.test(fileName); +} + +/** + * Checks a document's image references. `undefined` means this feature has + * nothing to say about the document at all, which is not the same as a + * checked document that produced no findings: the caller replaces a + * document's diagnostics and marks only when it gets checks back. + */ +async function checkImageReferencesInDocument(document: vscode.TextDocument, fetch: FetchLike): Promise { + if (!isHelmValuesFile(document)) { + return undefined; + } + + const version = document.version; + + // No guard around this: the extractor collects YAML syntax errors rather + // than throwing, so a malformed file yields whatever it could salvage. The + // open listener's own catch covers a genuine fault. + const references = extractImageReferences(document.getText()); + + // A tagless reference has nothing to ask a registry until `appVersion` + // resolution lands, but it still comes through as a check. Dropping it is + // what made real references render nothing, which reads as a broken tool. + const checks = await Promise.all( + references.map(async (reference) => ({ + reference, + verdict: + reference.tag === undefined + ? ({ kind: 'unverifiable', reason: 'no-tag' } as const) + : await checkImageExistence({ repository: reference.repository.text, tag: reference.tag.text, fetch }), + })) + ); + + return { version, checks }; +} + +/** + * A document's checks, or none once the text has moved on. Recorded offsets + * belong to the version that was checked, so projecting them onto edited + * text would slide a mark onto whatever now sits at that offset. + */ +function checksAsOf(checked: DocumentChecks, document: vscode.TextDocument): readonly ReferenceCheck[] { + return checked.version === document.version ? checked.checks : []; +} + +export { checkImageReferencesInDocument, checksAsOf, isHelmValuesFile }; +export type { DocumentChecks, ReferenceCheck, ReferenceVerdict, UncheckedReason }; diff --git a/apps/vscode/src/source-range.ts b/apps/vscode/src/source-range.ts new file mode 100644 index 0000000..c5eca8d --- /dev/null +++ b/apps/vscode/src/source-range.ts @@ -0,0 +1,12 @@ +import * as vscode from 'vscode'; +import type { SourceRange } from 'helm'; + +function rangeOf(document: vscode.TextDocument, range: SourceRange): vscode.Range { + return new vscode.Range(document.positionAt(range.start), document.positionAt(range.end)); +} + +function containsOffset(range: SourceRange, offset: number): boolean { + return offset >= range.start && offset < range.end; +} + +export { containsOffset, rangeOf }; diff --git a/apps/vscode/test/fake-document.ts b/apps/vscode/test/fake-document.ts new file mode 100644 index 0000000..9745590 --- /dev/null +++ b/apps/vscode/test/fake-document.ts @@ -0,0 +1,36 @@ +import * as vscode from 'vscode'; + +/** Builds a fake `vscode.TextDocument`, with a real `positionAt`/`offsetAt` pair so range assertions are exact. */ +function createFakeDocument(path: string, text: string, languageId = 'yaml'): vscode.TextDocument { + return { + uri: { path, toString: () => path }, + languageId, + version: 1, + getText: () => text, + positionAt: (offset: number) => { + const before = text.slice(0, offset); + const lines = before.split('\n'); + const line = lines.length - 1; + const character = lines[lines.length - 1]?.length ?? 0; + + return new vscode.Position(line, character); + }, + offsetAt: (position: vscode.Position) => { + const lines = text.split('\n'); + let offset = 0; + + for (let line = 0; line < position.line; line += 1) { + offset += (lines[line]?.length ?? 0) + '\n'.length; + } + + return offset + position.character; + }, + } as unknown as vscode.TextDocument; +} + +/** Simulates an edit: VS Code bumps a document's `version` on every change. */ +function bumpVersion(document: vscode.TextDocument): void { + (document as { version: number }).version += 1; +} + +export { bumpVersion, createFakeDocument }; diff --git a/apps/vscode/test/fake-fetch.ts b/apps/vscode/test/fake-fetch.ts new file mode 100644 index 0000000..bfad001 --- /dev/null +++ b/apps/vscode/test/fake-fetch.ts @@ -0,0 +1,14 @@ +const SUCCESS_STATUS_START = 200; +const SUCCESS_STATUS_END = 300; + +/** A canned fetch `Response`-shaped object for the injected fetch fake. */ +function fakeFetchResponse(status: number, body: unknown = {}): { status: number; ok: boolean; json: () => Promise } { + return { + status, + ok: status >= SUCCESS_STATUS_START && status < SUCCESS_STATUS_END, + // eslint-disable-next-line @typescript-eslint/promise-function-async -- trivial canned response, nothing to await + json: () => Promise.resolve(body), + }; +} + +export { fakeFetchResponse }; diff --git a/apps/vscode/test/vscode-stub.ts b/apps/vscode/test/vscode-stub.ts index ecdfe24..f8ec3c5 100644 --- a/apps/vscode/test/vscode-stub.ts +++ b/apps/vscode/test/vscode-stub.ts @@ -1,5 +1,3 @@ -import { vi } from 'vitest'; - /** * Minimal stand-in for the `vscode` module. * @@ -8,13 +6,11 @@ import { vi } from 'vitest'; * `import * as vscode from 'vscode'` in tests without booting a real VS Code * instance. Extend this stub as the extension grows — don't add per-file * `vi.mock('vscode', …)` factories. + * + * @packageDocumentation */ -const window = { - createOutputChannel: vi.fn(() => ({ - appendLine: vi.fn(), - dispose: vi.fn(), - })), -}; + +import { vi } from 'vitest'; class Position { public constructor( @@ -30,6 +26,25 @@ class Range { ) {} } +class ThemeColor { + public constructor(public readonly id: string) {} +} + +class MarkdownString { + public constructor(public readonly value: string = '') {} +} + +class Hover { + public readonly contents: (MarkdownString | string)[]; + + public constructor( + contents: MarkdownString | string | (MarkdownString | string)[], + public readonly range?: Range + ) { + this.contents = Array.isArray(contents) ? contents : [contents]; + } +} + // This reproduces the real `vscode.DiagnosticSeverity` enum's member names // and values exactly — extension code does `vscode.DiagnosticSeverity.Error` // against the real `@types/vscode` declaration, so the stub's runtime shape @@ -79,8 +94,26 @@ function createDiagnosticCollectionStub(): DiagnosticCollectionStub { return stub; } +/** The subset of `vscode.HoverProvider` the extension registers. */ +interface HoverProviderStub { + readonly provideHover: (document: unknown, position: unknown) => unknown; +} + +// Registered providers are removed again on dispose, so a test that disposes +// its context's subscriptions leaves no provider behind for the next one. +let hoverProviders: HoverProviderStub[] = []; + const languages = { createDiagnosticCollection: vi.fn(() => createDiagnosticCollectionStub()), + registerHoverProvider: vi.fn((_selector: unknown, provider: HoverProviderStub) => { + hoverProviders.push(provider); + + return { + dispose: vi.fn(() => { + hoverProviders = hoverProviders.filter((registered) => registered !== provider); + }), + }; + }), }; /** Test-only helper: the most recently created diagnostic collection. */ @@ -88,6 +121,25 @@ function getLastDiagnosticCollection(): DiagnosticCollectionStub | undefined { return diagnosticCollections[diagnosticCollections.length - 1]; } +/** Test-only helper: the most recently registered, still-undisposed hover provider. */ +function getRegisteredHoverProvider(): HoverProviderStub | undefined { + return hoverProviders[hoverProviders.length - 1]; +} + +/** + * Test-only stand-in for `vscode.TextEditor`: the document it shows, and a + * spy recording every `setDecorations` call made against it. + */ +interface TextEditorStub { + readonly document: unknown; + readonly setDecorations: ReturnType; +} + +/** Test-only helper that builds a {@link TextEditorStub}. Not part of the real `vscode` API. */ +function createTextEditorStub(document: unknown): TextEditorStub { + return { document, setDecorations: vi.fn() }; +} + /** * A minimal `vscode.Event`-shaped emitter: `event` is what extension code * subscribes through, `fire` is a test-only helper (not part of the real @@ -122,6 +174,17 @@ function createEventEmitterStub(): { } const onDidOpenTextDocumentEmitter = createEventEmitterStub(); +const onDidChangeVisibleTextEditorsEmitter = createEventEmitterStub(); + +const window = { + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + dispose: vi.fn(), + })), + createTextEditorDecorationType: vi.fn(() => ({ key: 'decoration-type', dispose: vi.fn() })), + visibleTextEditors: [] as readonly TextEditorStub[], + onDidChangeVisibleTextEditors: onDidChangeVisibleTextEditorsEmitter.event, +}; const workspace = { onDidOpenTextDocument: onDidOpenTextDocumentEmitter.event, @@ -136,4 +199,40 @@ async function emitDidOpenTextDocument(document: unknown): Promise { await onDidOpenTextDocumentEmitter.fire(document); } -export { Diagnostic, DiagnosticSeverity, emitDidOpenTextDocument, getLastDiagnosticCollection, languages, Position, Range, window, workspace }; +/** + * Test-only helper that replaces `window.visibleTextEditors`. Not part of the + * real `vscode` API — tests set it to stage which editors the extension can + * decorate, and reset it so one test's editors never leak into another. + */ +function setVisibleTextEditors(editors: readonly TextEditorStub[]): void { + window.visibleTextEditors = editors; +} + +/** + * Test-only helper that fires `window.onDidChangeVisibleTextEditors` after + * updating `window.visibleTextEditors`, the order real VS Code uses. + */ +async function emitDidChangeVisibleTextEditors(editors: readonly TextEditorStub[]): Promise { + setVisibleTextEditors(editors); + await onDidChangeVisibleTextEditorsEmitter.fire(editors); +} + +export type { TextEditorStub }; +export { + createTextEditorStub, + Diagnostic, + DiagnosticSeverity, + emitDidChangeVisibleTextEditors, + emitDidOpenTextDocument, + getLastDiagnosticCollection, + getRegisteredHoverProvider, + Hover, + languages, + MarkdownString, + Position, + Range, + setVisibleTextEditors, + ThemeColor, + window, + workspace, +};