Merge pull request 'Brand completion and unknown-brand errors on their line' (#15) from feat/brand-completion into main
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Changelog
|
# 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
|
## 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…*.
|
- The new-diagram command is now titled **New Diagram Archify…** everywhere, including the Explorer folder menu, where it read *New Diagram…*.
|
||||||
|
|||||||
@@ -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, <kbd>Cmd/Ctrl+K V</kbd> (to the side) or <kbd>Cmd/Ctrl+Shift+V</kbd>. The preview keeps the focus and route you selected (`#focus=…`, `#route=…`) when it re-renders.
|
- **Live preview**: for Archify's `*.architecture.json`-style files, or any JSON with a `diagram_type`. Use the preview button in the editor title, <kbd>Cmd/Ctrl+K V</kbd> (to the side) or <kbd>Cmd/Ctrl+Shift+V</kbd>. 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
- **Export**: the diagram's own Export menu (PNG, SVG, share card, …) saves through a VS Code save dialog.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Brand>[] };
|
||||||
|
return (catalog.marks ?? [])
|
||||||
|
.filter((mark): mark is Partial<Brand> & { 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;
|
||||||
|
}
|
||||||
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
import { parse, ParseError } from 'jsonc-parser';
|
import { parse, ParseError } from 'jsonc-parser';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
import { isArchifyFile, readDiagramSource } from './detect';
|
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 { ArchifyDiagnostic, isEvidenceError, validate } from './renderer';
|
||||||
import { evidenceMode, log, runtimeFor } from './runtime';
|
import { evidenceMode, log, runtimeFor } from './runtime';
|
||||||
|
|
||||||
@@ -133,7 +133,8 @@ export class DiagnosticsController implements vscode.Disposable {
|
|||||||
this.collection.set(
|
this.collection.set(
|
||||||
doc.uri,
|
doc.uri,
|
||||||
result.diagnostics.map((item) => {
|
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 range = new vscode.Range(doc.positionAt(offsets.start), doc.positionAt(offsets.end));
|
||||||
const fix = item.supportedFixes?.[0];
|
const fix = item.supportedFixes?.[0];
|
||||||
const diagnostic = new vscode.Diagnostic(
|
const diagnostic = new vscode.Diagnostic(
|
||||||
|
|||||||
+8
-1
@@ -1,6 +1,7 @@
|
|||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import * as vscode from 'vscode';
|
import * as vscode from 'vscode';
|
||||||
|
import { BRAND_COMPLETION_SELECTOR, BrandCompletionProvider } from './brandCompletion';
|
||||||
import { diagramBaseName, isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect';
|
import { diagramBaseName, isArchifyFile, isArchifyHtml, isDiagramFileName, readDiagramSource } from './detect';
|
||||||
import { DiagnosticsController } from './diagnostics';
|
import { DiagnosticsController } from './diagnostics';
|
||||||
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
|
import { HTML_VIEW_TYPE, HtmlViewerProvider } from './htmlViewer';
|
||||||
@@ -18,7 +19,13 @@ function isHtmlUri(uri: vscode.Uri): boolean {
|
|||||||
export function activate(context: vscode.ExtensionContext): void {
|
export function activate(context: vscode.ExtensionContext): void {
|
||||||
const previews = new PreviewManager(context);
|
const previews = new PreviewManager(context);
|
||||||
const htmlViewer = new HtmlViewerProvider();
|
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. */
|
/** The file a command applies to: explicit argument, focused preview or viewer, then the text editor. */
|
||||||
function targetUri(arg?: unknown): vscode.Uri | undefined {
|
function targetUri(arg?: unknown): vscode.Uri | undefined {
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ export interface OffsetRange {
|
|||||||
end: number;
|
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. */
|
/** Splits an RFC 6901 JSON pointer into path segments. */
|
||||||
export function pointerSegments(pointer: string): (string | number)[] {
|
export function pointerSegments(pointer: string): (string | number)[] {
|
||||||
if (!pointer || pointer === '/') return [];
|
if (!pointer || pointer === '/') return [];
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -131,6 +131,54 @@ export async function run(): Promise<void> {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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.CompletionList>(
|
||||||
|
'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 check('opens a live preview beside the source', async () => {
|
||||||
await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json'));
|
await vscode.commands.executeCommand('archify.showPreviewToSide', workspaceFile('production-deployment.architecture.json'));
|
||||||
const tabs = await waitFor('preview tab', () => {
|
const tabs = await waitFor('preview tab', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user