From adc5cdbe97a89bdec0200e2841e72510183f9fdc Mon Sep 17 00:00:00 2001 From: Root At Skic Date: Sun, 4 Oct 2026 22:05:30 +0300 Subject: [PATCH] Suggest brand IDs after brand: and place unknown-brand errors on their line - Completion for brand values in .archify (YAML) and Archify JSON sources, from the bundled brand-marks/catalog.json (107 marks); aliases and product names match, so "kafka" offers apache-kafka - brand/unknown diagnostics carry their JSON pointer only at the start of the message; use it when subject.path is missing, so the error lands on the brand line instead of the top of the file - Unit tests for catalog loading, value detection and pointer fallback; integration checks for completion and diagnostic placement Closes #5 --- CHANGELOG.md | 5 ++++ README.md | 1 + src/brandCompletion.ts | 48 +++++++++++++++++++++++++++++++++++++++ src/brands.ts | 33 +++++++++++++++++++++++++++ src/diagnostics.ts | 5 ++-- src/extension.ts | 9 +++++++- src/jsonPointer.ts | 8 +++++++ test/brands.test.ts | 44 +++++++++++++++++++++++++++++++++++ test/integration/suite.ts | 48 +++++++++++++++++++++++++++++++++++++++ 9 files changed, 198 insertions(+), 3 deletions(-) create mode 100644 src/brandCompletion.ts create mode 100644 src/brands.ts create mode 100644 test/brands.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b43811c..00ad338 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- **Brand completion:** after `brand:` in `.archify` files and Archify JSON sources, the editor suggests the 107 built-in brand IDs with their product names. Aliases and product names match too (`kafka` → `apache-kafka`, `postgres` → `postgresql`). +- **Unknown brands are underlined on their own line.** Archify reports these without a location, so they used to appear at the top of the file. + ## 0.4.1 — 2026-10-04 - The new-diagram command is now titled **New Diagram Archify…** everywhere, including the Explorer folder menu, where it read *New Diagram…*. diff --git a/README.md b/README.md index 8ec438c..837b0d4 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Archify's own JSON naming, `name.architecture.json`, `name.workflow.json` and so - **Live preview**: for Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, Cmd/Ctrl+K V (to the side) or Cmd/Ctrl+Shift+V. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders. - **HTML viewer**: right-click a rendered `.html` file and choose **Open in Archify Viewer**, or use *Reopen Editor With… → Archify Diagram Viewer*. For Archify HTML already open as text, a button appears in the editor title. The viewer reloads when the file changes on disk. - **Problems**: YAML syntax errors, plus `archify validate`'s schema, layout and evidence problems, appear at the matching line of the source. +- **Brand logos**: after `brand:` the editor suggests Archify's built-in brand IDs (PostgreSQL, Redis, Kafka, Docker, …). Typing an alias or product name finds them too, so `kafka` offers `apache-kafka`. An unknown brand is reported on its own line. - **Schema help**: completion and hover come from Archify's own schemas: built in for the JSON files, and through the YAML extension for `.archify` files. - **Source links**: in source-backed diagrams, clicking a source reference opens that file and line range in the editor. If the file is not in the workspace, the web link opens instead. - **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog. diff --git a/src/brandCompletion.ts b/src/brandCompletion.ts new file mode 100644 index 0000000..7d59bda --- /dev/null +++ b/src/brandCompletion.ts @@ -0,0 +1,48 @@ +import path from 'node:path'; +import * as vscode from 'vscode'; +import { Brand, brandValueAt, loadBrandCatalog } from './brands'; +import { isArchifyFile } from './detect'; +import { log } from './runtime'; + +const JSON_SOURCES = '**/*.{architecture,workflow,sequence,dataflow,lifecycle}.json'; + +export const BRAND_COMPLETION_SELECTOR: vscode.DocumentSelector = [ + { pattern: '**/*.archify' }, + { language: 'json', pattern: JSON_SOURCES }, + { language: 'jsonc', pattern: JSON_SOURCES }, +]; + +/** Suggests Archify's built-in brand IDs for `brand` values. */ +export class BrandCompletionProvider implements vscode.CompletionItemProvider { + private brands: Brand[] | undefined; + + constructor(private readonly extensionPath: string) {} + + private catalog(): Brand[] { + if (!this.brands) { + try { + this.brands = loadBrandCatalog(path.join(this.extensionPath, 'vendor', 'archify', 'brand-marks', 'catalog.json')); + } catch (error) { + log().error(`Could not read the Archify brand catalog: ${error instanceof Error ? error.message : String(error)}`); + this.brands = []; + } + } + return this.brands; + } + + provideCompletionItems(document: vscode.TextDocument, position: vscode.Position): vscode.CompletionItem[] | undefined { + const linePrefix = document.lineAt(position.line).text.slice(0, position.character); + const value = brandValueAt(linePrefix, isArchifyFile(document.fileName) ? 'yaml' : 'json'); + if (!value) return undefined; + const range = new vscode.Range(position.translate(0, -value.typed.length), position); + return this.catalog().map((brand) => { + const item = new vscode.CompletionItem({ label: brand.id, description: brand.title }, vscode.CompletionItemKind.Value); + item.detail = `${brand.title} · ${brand.category}`; + if (brand.aliases.length) item.documentation = `Also matches: ${brand.aliases.join(', ')}`; + // Typing an alias or the product name finds the brand too. + item.filterText = [brand.id, ...brand.aliases, brand.title].join(' '); + item.range = range; + return item; + }); + } +} diff --git a/src/brands.ts b/src/brands.ts new file mode 100644 index 0000000..c2f3c8f --- /dev/null +++ b/src/brands.ts @@ -0,0 +1,33 @@ +import fs from 'node:fs'; + +export interface Brand { + id: string; + title: string; + category: string; + aliases: string[]; +} + +/** Archify's built-in brand marks, read from brand-marks/catalog.json. */ +export function loadBrandCatalog(file: string): Brand[] { + const catalog = JSON.parse(fs.readFileSync(file, 'utf8')) as { marks?: Partial[] }; + return (catalog.marks ?? []) + .filter((mark): mark is Partial & { id: string } => typeof mark.id === 'string') + .map((mark) => ({ id: mark.id, title: mark.title ?? mark.id, category: mark.category ?? '', aliases: mark.aliases ?? [] })); +} + +export interface BrandValue { + /** What has been typed of the value so far. */ + typed: string; +} + +/** + * Whether the text before the cursor ends inside a `brand` value: + * `brand: post` or `{ id: db, brand: "post` in YAML, `"brand": "post` in JSON. + */ +export function brandValueAt(linePrefix: string, format: 'yaml' | 'json'): BrandValue | undefined { + const match = + format === 'yaml' + ? /(?:^|[\s{,])brand:\s*["']?([\w.-]*)$/.exec(linePrefix) + : /"brand"\s*:\s*"([\w.-]*)$/.exec(linePrefix); + return match ? { typed: match[1] } : undefined; +} diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 7fa489b..5097945 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -1,7 +1,7 @@ import { parse, ParseError } from 'jsonc-parser'; import * as vscode from 'vscode'; import { isArchifyFile, readDiagramSource } from './detect'; -import { rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer'; +import { diagnosticPointer, rangeForPointer, rangeForPointerYaml, yamlSyntaxProblems } from './jsonPointer'; import { ArchifyDiagnostic, isEvidenceError, validate } from './renderer'; import { evidenceMode, log, runtimeFor } from './runtime'; @@ -133,7 +133,8 @@ export class DiagnosticsController implements vscode.Disposable { this.collection.set( doc.uri, result.diagnostics.map((item) => { - const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, item.subject?.path) : rangeForPointer(text, item.subject?.path); + const pointer = diagnosticPointer(item); + const offsets = source.format === 'yaml' ? rangeForPointerYaml(text, pointer) : rangeForPointer(text, pointer); const range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end)); const fix = item.supportedFixes?.[0]; const diagnostic = new vscode.Diagnostic( diff --git a/src/extension.ts b/src/extension.ts index ca18ef9..7b3b467 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,7 @@ import fs from 'node:fs/promises'; import path from 'node:path'; import * as vscode from 'vscode'; +import { BRAND_COMPLETION_SELECTOR, BrandCompletionProvider } from './brandCompletion'; import { diagramBaseName, isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect'; import { DiagnosticsController } from './diagnostics'; import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer'; @@ -18,7 +19,13 @@ function isHtmlUri(uri: vscode.Uri): boolean { export function activate(context: vscode.ExtensionContext): void { const previews = new PreviewManager(context); const htmlViewer = new HtmlViewerProvider(); - context.subscriptions.push(previews, htmlViewer, new DiagnosticsController(context), log()); + context.subscriptions.push( + previews, + htmlViewer, + new DiagnosticsController(context), + log(), + vscode.languages.registerCompletionItemProvider(BRAND_COMPLETION_SELECTOR, new BrandCompletionProvider(context.extensionPath), ' ', '"'), + ); /** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */ function targetUri(arg?: unknown): vscode.Uri | undefined { diff --git a/src/jsonPointer.ts b/src/jsonPointer.ts index ce7fd17..fa97214 100644 --- a/src/jsonPointer.ts +++ b/src/jsonPointer.ts @@ -6,6 +6,14 @@ export interface OffsetRange { end: number; } +/** + * The JSON pointer an Archify diagnostic is about. Most diagnostics carry it + * in subject.path; brand diagnostics only start their message with it. + */ +export function diagnosticPointer(diagnostic: { message: string; subject?: { path?: string } }): string | undefined { + return diagnostic.subject?.path ?? /^(\/[^\s"]+)\s/.exec(diagnostic.message)?.[1]; +} + /** Splits an RFC 6901 JSON pointer into path segments. */ export function pointerSegments(pointer: string): (string | number)[] { if (!pointer || pointer === '/') return []; diff --git a/test/brands.test.ts b/test/brands.test.ts new file mode 100644 index 0000000..ac19170 --- /dev/null +++ b/test/brands.test.ts @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { test } from 'node:test'; +import { brandValueAt, loadBrandCatalog } from '../src/brands'; +import { diagnosticPointer } from '../src/jsonPointer'; + +const root = path.resolve(__dirname, '..', '..'); +const catalogFile = path.join(root, 'vendor', 'archify', 'brand-marks', 'catalog.json'); + +test('loads the bundled brand catalog', () => { + const brands = loadBrandCatalog(catalogFile); + assert.ok(brands.length >= 100, `${brands.length} brands`); + const postgres = brands.find((brand) => brand.id === 'postgresql'); + assert.ok(postgres); + assert.ok(postgres.aliases.includes('postgres')); + assert.ok(brands.every((brand) => brand.id && brand.title)); +}); + +test('finds a brand value being typed in YAML', () => { + assert.deepEqual(brandValueAt(' brand: ', 'yaml'), { typed: '' }); + assert.deepEqual(brandValueAt(' brand: post', 'yaml'), { typed: 'post' }); + assert.deepEqual(brandValueAt(' - brand: apache-ka', 'yaml'), { typed: 'apache-ka' }); + assert.deepEqual(brandValueAt(' brand: "red', 'yaml'), { typed: 'red' }); + assert.deepEqual(brandValueAt(' - { id: db, brand: post', 'yaml'), { typed: 'post' }); + assert.equal(brandValueAt(' label: post', 'yaml'), undefined); + assert.equal(brandValueAt(' subbrand: post', 'yaml'), undefined); + assert.equal(brandValueAt(' brand: postgresql ', 'yaml'), undefined, 'value already finished'); +}); + +test('finds a brand value being typed in JSON', () => { + assert.deepEqual(brandValueAt(' "brand": "', 'json'), { typed: '' }); + assert.deepEqual(brandValueAt('{ "id": "db", "brand": "post', 'json'), { typed: 'post' }); + assert.equal(brandValueAt(' "brand": ', 'json'), undefined, 'not inside the string yet'); + assert.equal(brandValueAt(' "label": "post', 'json'), undefined); +}); + +test('diagnostic pointer comes from subject.path, else from a leading pointer in the message', () => { + assert.equal(diagnosticPointer({ message: 'x', subject: { path: '/components/0' } }), '/components/0'); + assert.equal( + diagnosticPointer({ message: '/components/0/brand "aws" is not a built-in brand; closest IDs: airtable' }), + '/components/0/brand', + ); + assert.equal(diagnosticPointer({ message: 'Label "x" is wider than node' }), undefined); +}); diff --git a/test/integration/suite.ts b/test/integration/suite.ts index 3badf87..6358fb5 100644 --- a/test/integration/suite.ts +++ b/test/integration/suite.ts @@ -131,6 +131,54 @@ export async function run(): Promise { ); }); + await check('suggests brand IDs after brand: in .archify files', async () => { + const target = workspaceFile('brands.archify'); + const text = [ + 'schema_version: 1', + 'diagram_type: architecture', + 'meta:', + ' title: Brands', + 'components:', + ' - id: db', + ' type: database', + ' label: DB', + ' brand: ', + ' - id: bus', + ' type: messagebus', + ' label: Bus', + ' brand: kafka', + '', + ].join('\n'); + await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(text)); + const doc = await vscode.workspace.openTextDocument(target); + const labels = async (line: number) => { + const list = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + doc.uri, + new vscode.Position(line, doc.lineAt(line).text.length), + ); + return list.items.map((item) => (typeof item.label === 'string' ? item.label : item.label.label)); + }; + const empty = await labels(8); + assert.ok(empty.includes('postgresql') && empty.includes('redis'), 'brand IDs offered after "brand: "'); + const kafka = await labels(12); + assert.ok(kafka.includes('apache-kafka'), 'the alias "kafka" finds apache-kafka'); + assert.equal((await labels(7)).includes('postgresql'), false, 'nothing offered for other fields'); + }); + + await check('an unknown brand is reported on its own line', async () => { + const target = workspaceFile('unknown-brand.archify'); + const text = new TextDecoder().decode(await vscode.workspace.fs.readFile(workspaceFile('web-platform.archify'))) + .replace(/^( {2}- id: [^\n]+\n)/m, '$1 brand: not-a-brand\n'); + await vscode.workspace.fs.writeFile(target, new TextEncoder().encode(text)); + const doc = await vscode.workspace.openTextDocument(target); + await vscode.window.showTextDocument(doc); + const diagnostic = await waitFor('brand/unknown diagnostic', () => + vscode.languages.getDiagnostics(doc.uri).find((d) => d.source === 'archify' && d.code === 'brand/unknown'), + ); + assert.match(doc.lineAt(diagnostic.range.start.line).text, /brand: not-a-brand/); + }); + await check('opens a live preview beside the source', async () => { await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json')); const tabs = await waitFor('preview tab', () => {