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
This commit is contained in:
2026-10-04 22:05:30 +03:00
parent e9da971523
commit adc5cdbe97
9 changed files with 198 additions and 3 deletions
+48
View File
@@ -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;
});
}
}
+33
View File
@@ -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
View File
@@ -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(
+8 -1
View File
@@ -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 {
+8
View File
@@ -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 [];