From 8ea4beae5bac9b9dc7c79b40648359bc76c0e505 Mon Sep 17 00:00:00 2001 From: Juan Cruz Viotti Date: Fri, 13 Feb 2026 12:15:38 -0400 Subject: [PATCH] Heavily simplify the VS Code backend code Signed-off-by: Juan Cruz Viotti --- vscode/.vscode/launch.json | 2 +- vscode/src/commands/CommandExecutor.ts | 109 ---- vscode/src/diagnostics/DiagnosticManager.ts | 197 ------ vscode/src/extension.ts | 672 +++++++++++++------- vscode/src/panel/PanelManager.ts | 146 ----- vscode/src/types.ts | 10 - vscode/src/utils/fileUtils.ts | 288 --------- 7 files changed, 446 insertions(+), 978 deletions(-) delete mode 100644 vscode/src/commands/CommandExecutor.ts delete mode 100644 vscode/src/diagnostics/DiagnosticManager.ts delete mode 100644 vscode/src/panel/PanelManager.ts delete mode 100644 vscode/src/types.ts delete mode 100644 vscode/src/utils/fileUtils.ts diff --git a/vscode/.vscode/launch.json b/vscode/.vscode/launch.json index 8acb057..e14a96a 100644 --- a/vscode/.vscode/launch.json +++ b/vscode/.vscode/launch.json @@ -18,7 +18,7 @@ "request": "launch", "args": [ "--extensionDevelopmentPath=${workspaceFolder}", - "--extensionTestsPath=${workspaceFolder}/../test/vscode/index" + "--extensionTestsPath=${workspaceFolder}/../test/vscode" ], "outFiles": [ "${workspaceFolder}/../build/vscode/**/*.js" diff --git a/vscode/src/commands/CommandExecutor.ts b/vscode/src/commands/CommandExecutor.ts deleted file mode 100644 index 7822635..0000000 --- a/vscode/src/commands/CommandExecutor.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { spawn } from '@sourcemeta/jsonschema'; -import { CommandResult } from '../../../protocol/types'; - -/** - * Execute a CLI command and return the result - */ -export class CommandExecutor { - /** - * Execute a command with given arguments - */ - private async executeCommand(args: string[], json: boolean = true): Promise { - try { - const result = await spawn(args, { json }); - const output = json && typeof result.stdout !== 'string' - ? JSON.stringify(result.stdout) - : (result.stdout || result.stderr || 'No output'); - return { - output: typeof output === 'string' ? output.trim() : JSON.stringify(output).trim(), - exitCode: result.code - }; - } catch (error) { - throw error; - } - } - - /** - * Get the JSON Schema CLI version - */ - async getVersion(): Promise { - try { - const result = await this.executeCommand(['version'], false); - return result.exitCode === 0 ? result.output.trim() : `Error: ${result.output}`; - } catch (error) { - return `Error: ${(error as Error).message}`; - } - } - - /** - * Run lint command on a file - */ - async lint(filePath: string, useHttp: boolean = true): Promise { - try { - const args = ['lint']; - if (useHttp) { - args.push('--http'); - } - args.push(filePath); - const result = await this.executeCommand(args, true); - return result.output; - } catch (error) { - throw error; - } - } - - /** - * Run format check command on a file - */ - async formatCheck(filePath: string, useHttp: boolean = true): Promise { - try { - const args = ['fmt', '--check']; - if (useHttp) { - args.push('--http'); - } - args.push(filePath); - return await this.executeCommand(args, true); - } catch (error) { - throw error; - } - } - - /** - * Run format command on a file - */ - async format(filePath: string, useHttp: boolean = true): Promise { - const args = ['fmt']; - if (useHttp) { - args.push('--http'); - } - args.push(filePath); - const result = await this.executeCommand(args, true); - if (result.exitCode !== 0) { - try { - const errorObj = JSON.parse(result.output); - if (errorObj.error) { - throw new Error(errorObj.error); - } - } catch { - // If JSON parsing fails, use the raw output - } - throw new Error(result.output || `Process exited with code ${result.exitCode}`); - } - } - - /** - * Run metaschema validation on a file - */ - async metaschema(filePath: string, useHttp: boolean = true): Promise { - try { - const args = ['metaschema']; - if (useHttp) { - args.push('--http'); - } - args.push(filePath); - return await this.executeCommand(args, true); - } catch (error) { - throw error; - } - } -} diff --git a/vscode/src/diagnostics/DiagnosticManager.ts b/vscode/src/diagnostics/DiagnosticManager.ts deleted file mode 100644 index 6f3002e..0000000 --- a/vscode/src/diagnostics/DiagnosticManager.ts +++ /dev/null @@ -1,197 +0,0 @@ -import * as vscode from 'vscode'; -import { DiagnosticType } from '../types'; -import { LintError, CliError, MetaschemaError, Position } from '../../../protocol/types'; -import { errorPositionToRange } from '../utils/fileUtils'; - -/** - * Manages VS Code diagnostics for lint and metaschema errors - */ -export class DiagnosticManager { - private lintDiagnostics: vscode.DiagnosticCollection; - private metaschemaDiagnostics: vscode.DiagnosticCollection; - - constructor() { - this.lintDiagnostics = vscode.languages.createDiagnosticCollection('sourcemeta-studio-lint'); - this.metaschemaDiagnostics = vscode.languages.createDiagnosticCollection('sourcemeta-studio-metaschema'); - } - - /** - * Update diagnostics for a document - */ - updateDiagnostics( - documentUri: vscode.Uri, - errors: LintError[], - type: DiagnosticType - ): void { - const diagnostics = errors - .filter((error): error is LintError & { position: Position } => - error.position !== null) - .map(error => { - const range = errorPositionToRange(error.position); - - const diagnostic = new vscode.Diagnostic( - range, - error.message, - type === DiagnosticType.Lint - ? vscode.DiagnosticSeverity.Warning - : vscode.DiagnosticSeverity.Error - ); - - // Set the source - diagnostic.source = type === DiagnosticType.Lint - ? 'Sourcemeta Studio (Lint)' - : 'Sourcemeta Studio (Metaschema)'; - - if (error.id) { - diagnostic.code = { - value: error.id, - // TODO: link to JSON Schema linting rules markdown repo - target: vscode.Uri.parse(`https://github.com/Karan-Palan/json-schema-lint-rules/tree/main/docs/${error.id}.md`) - }; - } - - const relatedInfo: vscode.DiagnosticRelatedInformation[] = []; - - if (error.description) { - relatedInfo.push( - new vscode.DiagnosticRelatedInformation( - new vscode.Location(documentUri, range), - ` ${error.description}` - ) - ); - } - - if (error.path) { - relatedInfo.push( - new vscode.DiagnosticRelatedInformation( - new vscode.Location(documentUri, range), - ` Path: ${error.path}` - ) - ); - } - - if (error.schemaLocation) { - relatedInfo.push( - new vscode.DiagnosticRelatedInformation( - new vscode.Location(documentUri, range), - ` Schema Location: ${error.schemaLocation}` - ) - ); - } - - if (relatedInfo.length > 0) { - diagnostic.relatedInformation = relatedInfo; - } - - return diagnostic; - }); - - const collection = type === DiagnosticType.Lint - ? this.lintDiagnostics - : this.metaschemaDiagnostics; - - collection.set(documentUri, diagnostics); - } - - updateMetaschemaDiagnostics( - documentUri: vscode.Uri, - errors: (MetaschemaError | CliError)[] | undefined - ): void { - if (!errors) { - this.metaschemaDiagnostics.set(documentUri, []); - return; - } - - const diagnostics = errors - .filter((error): error is MetaschemaError & { instancePosition: Position } => { - return 'instancePosition' in error && error.instancePosition !== undefined; - }) - .map(error => { - const range = errorPositionToRange(error.instancePosition); - - const diagnostic = new vscode.Diagnostic( - range, - error.error, - vscode.DiagnosticSeverity.Error - ); - - diagnostic.source = 'Sourcemeta Studio (Metaschema)'; - - if (error.instanceLocation !== undefined) { - diagnostic.code = error.instanceLocation; - } - - const relatedInfo: vscode.DiagnosticRelatedInformation[] = []; - - if (error.instanceLocation) { - relatedInfo.push( - new vscode.DiagnosticRelatedInformation( - new vscode.Location(documentUri, range), - ` Instance Location: ${error.instanceLocation}` - ) - ); - } - - if (error.keywordLocation) { - relatedInfo.push( - new vscode.DiagnosticRelatedInformation( - new vscode.Location(documentUri, range), - ` Keyword Location: ${error.keywordLocation}` - ) - ); - } - - if (error.absoluteKeywordLocation) { - relatedInfo.push( - new vscode.DiagnosticRelatedInformation( - new vscode.Location(documentUri, range), - ` Absolute Keyword Location: ${error.absoluteKeywordLocation}` - ) - ); - } - - if (relatedInfo.length > 0) { - diagnostic.relatedInformation = relatedInfo; - } - - return diagnostic; - }); - - this.metaschemaDiagnostics.set(documentUri, diagnostics); - } - - /** - * Clear diagnostics for a document - */ - clearDiagnostics(documentUri: vscode.Uri, type?: DiagnosticType): void { - if (!type || type === DiagnosticType.Lint) { - this.lintDiagnostics.delete(documentUri); - } - if (!type || type === DiagnosticType.Metaschema) { - this.metaschemaDiagnostics.delete(documentUri); - } - } - - /** - * Clear all diagnostics - */ - clearAll(): void { - this.lintDiagnostics.clear(); - this.metaschemaDiagnostics.clear(); - } - - /** - * Dispose of the diagnostic collections - */ - dispose(): void { - this.lintDiagnostics.dispose(); - this.metaschemaDiagnostics.dispose(); - } - - /** - * Get the diagnostic collections for registration - */ - getCollections(): vscode.DiagnosticCollection[] { - return [this.lintDiagnostics, this.metaschemaDiagnostics]; - } -} diff --git a/vscode/src/extension.ts b/vscode/src/extension.ts index fbdf4ab..e4644a2 100644 --- a/vscode/src/extension.ts +++ b/vscode/src/extension.ts @@ -1,96 +1,314 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; -import { PanelManager } from './panel/PanelManager'; -import { CommandExecutor } from './commands/CommandExecutor'; -import { DiagnosticManager } from './diagnostics/DiagnosticManager'; -import { getFileInfo, parseLintResult, parseMetaschemaResult, errorPositionToRange, parseCliError, hasJsonParseErrors } from './utils/fileUtils'; -import { PanelState, WebviewToExtensionMessage } from '../../protocol/types'; -import { DiagnosticType } from './types'; - -let panelManager: PanelManager; -let commandExecutor: CommandExecutor; -let diagnosticManager: DiagnosticManager; +import { spawn } from '@sourcemeta/jsonschema'; +import { PanelState, WebviewToExtensionMessage, CommandResult, FileInfo, LintResult, LintError, MetaschemaResult, MetaschemaError, CliError, Position } from '../../protocol/types'; + +let panel: vscode.WebviewPanel | undefined; +let lintDiagnostics: vscode.DiagnosticCollection; +let metaschemaDiagnostics: vscode.DiagnosticCollection; let lastActiveTextEditor: vscode.TextEditor | undefined; let cachedCliVersion = 'Loading...'; let extensionVersion = 'Loading...'; let currentPanelState: PanelState | null = null; let webviewReady = false; -/** - * Extension activation - */ -export async function activate(context: vscode.ExtensionContext): Promise { +async function executeCommand(args: string[]): Promise { + const result = await spawn(args, { json: true }); + const output = typeof result.stdout === 'string' + ? result.stdout + : JSON.stringify(result.stdout); + return { output: output.trim(), exitCode: result.code }; +} + +async function getVersion(): Promise { try { - const packageJsonPath = path.join(context.extensionPath, 'package.json'); - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); - extensionVersion = packageJson.version || 'Unknown'; - } catch { - extensionVersion = 'Unknown'; + const result = await spawn(['version'], { json: false }); + const output = ((result.stdout as string) || '').trim(); + return result.code === 0 ? output : `Error: ${output}`; + } catch (error) { + return `Error: ${(error as Error).message}`; } +} - // Disable VS Code's built-in JSON validation if configured - const config = vscode.workspace.getConfiguration('sourcemeta-studio'); - if (config.get('disableBuiltInValidation', true)) { - // Only disable validation if a workspace is open to avoid changing global user settings - if (vscode.workspace.workspaceFolders) { - await vscode.workspace.getConfiguration('json').update( - 'validate.enable', - false, - vscode.ConfigurationTarget.Workspace - ); +function parseCliError(output: string): CliError | null { + try { + const parsed = JSON.parse(output); + if (parsed.error && typeof parsed.error === 'string') { + return parsed as CliError; } + } catch { + } + return null; +} + +function hasJsonParseErrors(lintResult: LintResult, metaschemaResult: MetaschemaResult): boolean { + const lintHasParseError = lintResult.errors?.some(error => + error.id === 'json-parse-error' || + error.message.toLowerCase().includes('failed to parse') + ); + const metaschemaHasParseError = metaschemaResult.errors?.some(error => + error.error.toLowerCase().includes('failed to parse') + ); + return !!(lintHasParseError || metaschemaHasParseError); +} + +function getFileInfo(filePath: string | undefined): FileInfo | null { + if (!filePath) { + return null; } - panelManager = new PanelManager(context.extensionPath); - commandExecutor = new CommandExecutor(); - diagnosticManager = new DiagnosticManager(); + const extension = path.extname(filePath).toLowerCase(); + if (!['.json', '.yaml', '.yml'].includes(extension)) { + return null; + } - diagnosticManager.getCollections().forEach(collection => { - context.subscriptions.push(collection); - }); + let displayPath = filePath; + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (workspaceRoot && filePath.startsWith(workspaceRoot)) { + displayPath = path.relative(workspaceRoot, filePath); + } - panelManager.setMessageHandler((message: WebviewToExtensionMessage) => { - handleWebviewMessage(message); - }); + let lineCount = 0; + try { + lineCount = fs.readFileSync(filePath, 'utf-8').split('\n').length; + } catch { + } + + return { + absolutePath: filePath, + displayPath, + fileName: path.basename(filePath), + lineCount, + isYaml: extension === '.yaml' || extension === '.yml' + }; +} - panelManager.setDisposeHandler(() => { - diagnosticManager.clearAll(); - }); +function parseLintResult(lintOutput: string): LintResult { + try { + const parsed = JSON.parse(lintOutput); + + if (parsed.error && typeof parsed.error === 'string' && + typeof parsed.line === 'number' && typeof parsed.column === 'number' && + parsed.filePath && !parsed.identifier) { + return { + raw: lintOutput, + health: 0, + valid: false, + errors: [{ + id: 'json-parse-error', + message: parsed.error, + description: `Failed to parse JSON document at line ${parsed.line}, column ${parsed.column}`, + path: '/', + schemaLocation: '/', + position: [parsed.line, parsed.column, parsed.line, parsed.column] + }] + }; + } - const openPanelCommand = vscode.commands.registerCommand('sourcemeta-studio.openPanel', () => { - webviewReady = false; - panelManager.createOrReveal(context); - updatePanelContent(); - }); + if (parsed.error && !parsed.health && !Array.isArray(parsed.errors)) { + const hasPosition = typeof parsed.line === 'number' && typeof parsed.column === 'number'; + let description = parsed.error; - const isWebviewReadyCommand = vscode.commands.registerCommand('sourcemeta-studio.isWebviewReady', () => { - return webviewReady; - }); + if (parsed.filePath) { + description = `Error in ${parsed.filePath}`; + if (hasPosition) { + description += ` at line ${parsed.line}, column ${parsed.column}`; + } + } - const activeEditorChangeListener = vscode.window.onDidChangeActiveTextEditor((editor) => { - handleActiveEditorChange(editor); - }); + return { + raw: lintOutput, + health: 0, + valid: false, + errors: [{ + id: parsed.identifier ? 'cli-error-with-id' : 'cli-error', + message: parsed.error, + description: description, + path: parsed.location || '/', + schemaLocation: parsed.identifier || '/', + position: hasPosition ? [parsed.line, parsed.column, parsed.line, parsed.column] : null + }] + }; + } - if (vscode.window.activeTextEditor) { - lastActiveTextEditor = vscode.window.activeTextEditor; + return { + raw: lintOutput, + health: parsed.health, + valid: parsed.valid, + errors: parsed.errors || [] + }; + } catch { + return { raw: lintOutput, health: null, error: true }; + } +} + +function parseMetaschemaResult(output: string, exitCode: number | null): MetaschemaResult { + const result: MetaschemaResult = { output, exitCode }; + + if (exitCode === 1) { + const cliError = parseCliError(output); + if (cliError) { + result.errors = [{ + error: cliError.error, + instanceLocation: cliError.location || '/', + keywordLocation: '/', + absoluteKeywordLocation: cliError.identifier, + instancePosition: cliError.line && cliError.column + ? [cliError.line, cliError.column, cliError.line, cliError.column] + : undefined + }]; + return result; + } } - const documentSaveListener = vscode.workspace.onDidSaveTextDocument((document) => { - handleDocumentSave(document); - }); + // TODO(upstream): `metaschema --json` prepends the file path before the + // JSON object and wraps errors in `{ valid, errors: [...] }`. Until the + // CLI returns clean JSON on stdout, extract the errors array by hand. + // Reproduce: jsonschema metaschema --http --json invalid-metaschema.json + if (exitCode === 2) { + try { + let jsonStr = output.trim(); + const jsonStart = jsonStr.indexOf('['); + const jsonEnd = jsonStr.lastIndexOf(']'); + if (jsonStart !== -1 && jsonEnd !== -1 && jsonStart < jsonEnd) { + jsonStr = jsonStr.substring(jsonStart, jsonEnd + 1); + } - context.subscriptions.push( - openPanelCommand, - isWebviewReadyCommand, - activeEditorChangeListener, - documentSaveListener + const parsed = JSON.parse(jsonStr); + if (Array.isArray(parsed)) { + result.errors = parsed.map((entry: { + error?: string; + instanceLocation?: string; + keywordLocation?: string; + absoluteKeywordLocation?: string; + instancePosition?: Position; + }) => ({ + error: entry.error || 'Validation error', + instanceLocation: entry.instanceLocation || '', + keywordLocation: entry.keywordLocation || '', + absoluteKeywordLocation: entry.absoluteKeywordLocation, + instancePosition: entry.instancePosition + })); + } + } catch { + } + } + + return result; +} + +function errorPositionToRange(position: Position): vscode.Range { + const [lineStart, columnStart, lineEnd, columnEnd] = position; + + if (lineStart === 1 && columnStart === 1 && (lineEnd > lineStart || columnEnd > columnStart)) { + return new vscode.Range( + new vscode.Position(0, 0), + new vscode.Position(0, 0) + ); + } + + return new vscode.Range( + new vscode.Position(lineStart - 1, columnStart - 1), + new vscode.Position(lineEnd - 1, columnEnd) ); } -/** - * Handle messages from the webview - */ +function buildPanelState(fileInfo: FileInfo | null, overrides?: Partial): PanelState { + return { + fileInfo, + cliVersion: cachedCliVersion, + extensionVersion, + lintResult: { raw: '', health: null }, + formatResult: { output: '', exitCode: null }, + metaschemaResult: { output: '', exitCode: null }, + isLoading: false, + hasParseErrors: false, + ...overrides + }; +} + +function updatePanel(state: PanelState): void { + panel?.webview.postMessage({ type: 'update', state }); +} + +function buildRelatedInfo( + location: vscode.Location, + messages: (string | undefined | false | null)[] +): vscode.DiagnosticRelatedInformation[] | undefined { + const entries = messages + .filter((message): message is string => !!message) + .map(message => new vscode.DiagnosticRelatedInformation(location, message)); + return entries.length > 0 ? entries : undefined; +} + +function updateLintDiagnostics(documentUri: vscode.Uri, errors: LintError[]): void { + const diagnostics = errors + .filter((error): error is LintError & { position: Position } => + error.position !== null) + .map(error => { + const range = errorPositionToRange(error.position); + const diagnostic = new vscode.Diagnostic( + range, error.message, vscode.DiagnosticSeverity.Warning + ); + + diagnostic.source = 'Sourcemeta Studio (Lint)'; + + if (error.id) { + diagnostic.code = { + value: error.id, + target: vscode.Uri.parse(`https://github.com/Karan-Palan/json-schema-lint-rules/tree/main/docs/${error.id}.md`) + }; + } + + diagnostic.relatedInformation = buildRelatedInfo( + new vscode.Location(documentUri, range), [ + error.description && ` ${error.description}`, + error.path && ` Path: ${error.path}`, + error.schemaLocation && ` Schema Location: ${error.schemaLocation}` + ] + ); + + return diagnostic; + }); + + lintDiagnostics.set(documentUri, diagnostics); +} + +function updateMetaschemaDiagnostics( + documentUri: vscode.Uri, + errors: (MetaschemaError | CliError)[] +): void { + const diagnostics = errors + .filter((error): error is MetaschemaError & { instancePosition: Position } => { + return 'instancePosition' in error && error.instancePosition !== undefined; + }) + .map(error => { + const range = errorPositionToRange(error.instancePosition); + const diagnostic = new vscode.Diagnostic( + range, error.error, vscode.DiagnosticSeverity.Error + ); + + diagnostic.source = 'Sourcemeta Studio (Metaschema)'; + + if (error.instanceLocation !== undefined) { + diagnostic.code = error.instanceLocation; + } + + diagnostic.relatedInformation = buildRelatedInfo( + new vscode.Location(documentUri, range), [ + error.instanceLocation && ` Instance Location: ${error.instanceLocation}`, + error.keywordLocation && ` Keyword Location: ${error.keywordLocation}`, + error.absoluteKeywordLocation && ` Absolute Keyword Location: ${error.absoluteKeywordLocation}` + ] + ); + + return diagnostic; + }); + + metaschemaDiagnostics.set(documentUri, diagnostics); +} + function handleWebviewMessage(message: WebviewToExtensionMessage): void { if (message.command === 'ready') { webviewReady = true; @@ -100,25 +318,19 @@ function handleWebviewMessage(message: WebviewToExtensionMessage): void { if (message.command === 'goToPosition' && lastActiveTextEditor && message.position) { const range = errorPositionToRange(message.position); - - const showOptions: vscode.TextDocumentShowOptions = { - preserveFocus: false - }; - if (lastActiveTextEditor.viewColumn !== undefined) { - showOptions.viewColumn = lastActiveTextEditor.viewColumn; - } - vscode.window.showTextDocument(lastActiveTextEditor.document, showOptions).then((editor) => { + vscode.window.showTextDocument(lastActiveTextEditor.document, { + preserveFocus: false, + viewColumn: lastActiveTextEditor.viewColumn + }).then((editor) => { editor.selection = new vscode.Selection(range.start, range.end); - editor.revealRange(range, vscode.TextEditorRevealType.InCenter); }); } else if (message.command === 'openExternal' && message.url) { vscode.env.openExternal(vscode.Uri.parse(message.url)); } else if (message.command === 'formatSchema' && lastActiveTextEditor) { const filePath = lastActiveTextEditor.document.uri.fsPath; - const fileInfo = getFileInfo(filePath); - - if (!fileInfo || !panelManager.exists() || !currentPanelState) { + + if (!getFileInfo(filePath) || !panel || !currentPanelState) { return; } @@ -127,220 +339,226 @@ function handleWebviewMessage(message: WebviewToExtensionMessage): void { return; } - // Send format loading state only, preserve existing lint/metaschema state - panelManager.updateContent({ - ...currentPanelState, - formatLoading: true - }); + updatePanel({ ...currentPanelState, formatLoading: true }); + + executeCommand(['fmt', '--http', filePath]).then(async (result) => { + if (result.exitCode !== 0) { + throw new Error(result.output || `Process exited with code ${result.exitCode}`); + } - commandExecutor.format(filePath).then(async () => { if (lastActiveTextEditor) { await vscode.window.showTextDocument(lastActiveTextEditor.document, lastActiveTextEditor.viewColumn); } - - // Wait for Huge schemas to reload after formatting - await new Promise(resolve => setTimeout(resolve, 300)); + await new Promise(resolve => setTimeout(resolve, 300)); await updatePanelContent(); }).catch((error) => { - let errorMessage = error.message; - - // Try to parse JSON error from CLI const cliError = parseCliError(error.message); - if (cliError) { - errorMessage = cliError.error; - if (cliError.line) { - errorMessage += ` at line ${cliError.line}`; - if (cliError.column) { - errorMessage += `, column ${cliError.column}`; - } + let errorMessage = cliError?.error ?? error.message; + if (cliError?.line) { + errorMessage += ` at line ${cliError.line}`; + if (cliError.column) { + errorMessage += `, column ${cliError.column}`; } } - + vscode.window.showErrorMessage(`Format failed: ${errorMessage}`); if (currentPanelState) { - const updatedState = { + currentPanelState = { ...currentPanelState, formatResult: { output: `Error: ${errorMessage}`, exitCode: null }, formatLoading: false }; - currentPanelState = updatedState; - panelManager.updateContent(updatedState); + updatePanel(currentPanelState); } }); } } -/** - * Handle active editor changes - */ function handleActiveEditorChange(editor: vscode.TextEditor | undefined): void { - if (panelManager.exists() && editor && editor.document.uri.scheme === 'file') { - const panelColumn = panelManager.getPanel()?.viewColumn; - const editorColumn = vscode.window.activeTextEditor?.viewColumn; - - if (panelColumn === editorColumn) { - const targetColumn = panelColumn === vscode.ViewColumn.One - ? vscode.ViewColumn.Two - : vscode.ViewColumn.One; - - vscode.commands.executeCommand('workbench.action.closeActiveEditor').then(() => { - vscode.window.showTextDocument(editor.document, { - viewColumn: targetColumn, - preview: false - }).then(() => { - lastActiveTextEditor = vscode.window.activeTextEditor; - // Only update if the file actually changed - updatePanelContent(); - }); - }); - } else { - const previousFile = lastActiveTextEditor?.document.uri.fsPath; - lastActiveTextEditor = editor; + if (!editor || editor.document.uri.scheme !== 'file') { + return; + } - if (previousFile !== editor.document.uri.fsPath) { + const editorColumn = vscode.window.activeTextEditor?.viewColumn; + if (panel && panel.viewColumn === editorColumn) { + const targetColumn = panel.viewColumn === vscode.ViewColumn.One + ? vscode.ViewColumn.Two + : vscode.ViewColumn.One; + + vscode.commands.executeCommand('workbench.action.closeActiveEditor').then(() => { + vscode.window.showTextDocument(editor.document, { + viewColumn: targetColumn, + preview: false + }).then(() => { + lastActiveTextEditor = vscode.window.activeTextEditor; updatePanelContent(); - } - } - } else if (editor && editor.document.uri.scheme === 'file') { - const previousFile = lastActiveTextEditor?.document.uri.fsPath; - lastActiveTextEditor = editor; + }); + }); + return; + } - if (panelManager.exists() && previousFile !== editor.document.uri.fsPath) { - updatePanelContent(); - } + const previousFile = lastActiveTextEditor?.document.uri.fsPath; + lastActiveTextEditor = editor; + if (panel && previousFile !== editor.document.uri.fsPath) { + updatePanelContent(); } } -/** - * Handle document save events - */ function handleDocumentSave(document: vscode.TextDocument): void { - if (panelManager.exists() && lastActiveTextEditor && - document.uri.fsPath === lastActiveTextEditor.document.uri.fsPath) { - const fileInfo = getFileInfo(document.uri.fsPath); - // Only refresh if it's a JSON/YAML file - if (fileInfo) { - updatePanelContent(); + if (panel && lastActiveTextEditor && + document.uri.fsPath === lastActiveTextEditor.document.uri.fsPath && + getFileInfo(document.uri.fsPath)) { + updatePanelContent(); + } +} + +function createOrRevealPanel(context: vscode.ExtensionContext): void { + const columnToShowIn = vscode.window.activeTextEditor + ? vscode.ViewColumn.Beside + : vscode.ViewColumn.One; + + if (panel) { + panel.reveal(columnToShowIn, true); + return; + } + + panel = vscode.window.createWebviewPanel( + 'sourcemetaStudio', + 'Sourcemeta Studio', + { viewColumn: columnToShowIn, preserveFocus: false }, + { + enableScripts: true, + retainContextWhenHidden: true, + localResourceRoots: [ + vscode.Uri.file(context.extensionPath), + vscode.Uri.file(path.join(context.extensionPath, '..', 'build', 'webview')) + ] } + ); + + panel.iconPath = vscode.Uri.file(path.join(context.extensionPath, 'logo.png')); + + const productionPath = path.join(context.extensionPath, 'index.html'); + if (fs.existsSync(productionPath)) { + panel.webview.html = fs.readFileSync(productionPath, 'utf-8'); + } else { + const devPath = path.join(context.extensionPath, '..', 'build', 'webview', 'index.html'); + panel.webview.html = fs.readFileSync(devPath, 'utf-8'); } + + panel.webview.onDidReceiveMessage( + handleWebviewMessage, undefined, context.subscriptions + ); + + panel.onDidDispose(() => { + panel = undefined; + lintDiagnostics.clear(); + metaschemaDiagnostics.clear(); + }, null, context.subscriptions); } -/** - * Update the panel content with current file analysis - */ async function updatePanelContent(): Promise { - if (!panelManager.exists()) { + if (!panel) { return; } const filePath = lastActiveTextEditor?.document.uri.fsPath; const fileInfo = getFileInfo(filePath); - // If no file is selected, show a "no file" state without running commands if (!fileInfo) { - const noFileState: PanelState = { - fileInfo: null, - cliVersion: cachedCliVersion || 'Unknown', - extensionVersion, - lintResult: { raw: '', health: null }, - formatResult: { output: '', exitCode: null }, - metaschemaResult: { output: '', exitCode: null }, - isLoading: false, - hasParseErrors: false, - noFileSelected: true - }; - currentPanelState = noFileState; - panelManager.updateContent(noFileState); + currentPanelState = buildPanelState(null, { noFileSelected: true }); + updatePanel(currentPanelState); return; } - // Send initial loading state - const loadingState: PanelState = { - fileInfo, - cliVersion: cachedCliVersion, - extensionVersion, - lintResult: { raw: '', health: null }, - formatResult: { output: '', exitCode: null }, - metaschemaResult: { output: '', exitCode: null }, - isLoading: true, - hasParseErrors: false - }; - panelManager.updateContent(loadingState); + updatePanel(buildPanelState(fileInfo, { isLoading: true })); if (lastActiveTextEditor) { - diagnosticManager.clearDiagnostics(lastActiveTextEditor.document.uri); + lintDiagnostics.delete(lastActiveTextEditor.document.uri); + metaschemaDiagnostics.delete(lastActiveTextEditor.document.uri); } try { - const version = await commandExecutor.getVersion(); - cachedCliVersion = version; + cachedCliVersion = await getVersion(); - const [metaschemaRawResult, lintOutput, formatResult] = await Promise.all([ - commandExecutor.metaschema(fileInfo.absolutePath), - commandExecutor.lint(fileInfo.absolutePath), - commandExecutor.formatCheck(fileInfo.absolutePath) + const [metaschemaRawResult, lintRawResult, formatResult] = await Promise.all([ + executeCommand(['metaschema', '--http', fileInfo.absolutePath]), + executeCommand(['lint', '--http', fileInfo.absolutePath]), + executeCommand(['fmt', '--check', '--http', fileInfo.absolutePath]) ]); const metaschemaResult = parseMetaschemaResult(metaschemaRawResult.output, metaschemaRawResult.exitCode); - const lintResult = parseLintResult(lintOutput); - - const parseErrors = hasJsonParseErrors(lintResult, metaschemaResult); - - const finalState: PanelState = { - fileInfo, - cliVersion: cachedCliVersion, - extensionVersion, - lintResult, - formatResult, - metaschemaResult, - isLoading: false, - hasParseErrors: parseErrors - }; - currentPanelState = finalState; - panelManager.updateContent(finalState); + const lintResult = parseLintResult(lintRawResult.output); + + currentPanelState = buildPanelState(fileInfo, { + lintResult, formatResult, metaschemaResult, + hasParseErrors: hasJsonParseErrors(lintResult, metaschemaResult) + }); + updatePanel(currentPanelState); - // Update lint diagnostics if (lastActiveTextEditor && lintResult.errors && lintResult.errors.length > 0) { - diagnosticManager.updateDiagnostics( - lastActiveTextEditor.document.uri, - lintResult.errors, - DiagnosticType.Lint - ); + updateLintDiagnostics(lastActiveTextEditor.document.uri, lintResult.errors); } - // Update metaschema diagnostics if (lastActiveTextEditor && metaschemaResult.errors && metaschemaResult.errors.length > 0) { - diagnosticManager.updateMetaschemaDiagnostics( - lastActiveTextEditor.document.uri, - metaschemaResult.errors - ); + updateMetaschemaDiagnostics(lastActiveTextEditor.document.uri, metaschemaResult.errors); } } catch (error) { - cachedCliVersion = `Error: ${(error as Error).message}`; - const errorState: PanelState = { - fileInfo, - cliVersion: cachedCliVersion, - extensionVersion, - lintResult: { raw: `Error: ${(error as Error).message}`, health: null, error: true }, - formatResult: { output: `Error: ${(error as Error).message}`, exitCode: null }, - metaschemaResult: { output: `Error: ${(error as Error).message}`, exitCode: null }, - isLoading: false, + const errorMessage = (error as Error).message; + cachedCliVersion = `Error: ${errorMessage}`; + currentPanelState = buildPanelState(fileInfo, { + lintResult: { raw: `Error: ${errorMessage}`, health: null, error: true }, + formatResult: { output: `Error: ${errorMessage}`, exitCode: null }, + metaschemaResult: { output: `Error: ${errorMessage}`, exitCode: null }, hasParseErrors: true - }; - currentPanelState = errorState; - panelManager.updateContent(errorState); + }); + updatePanel(currentPanelState); } } -/** - * Extension deactivation - */ -export function deactivate(): void { - if (panelManager) { - panelManager.dispose(); +export async function activate(context: vscode.ExtensionContext): Promise { + try { + const packageJsonPath = path.join(context.extensionPath, 'package.json'); + const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + extensionVersion = packageJson.version || 'Unknown'; + } catch { + extensionVersion = 'Unknown'; } - if (diagnosticManager) { - diagnosticManager.dispose(); + + const config = vscode.workspace.getConfiguration('sourcemeta-studio'); + if (config.get('disableBuiltInValidation', true)) { + if (vscode.workspace.workspaceFolders) { + await vscode.workspace.getConfiguration('json').update( + 'validate.enable', + false, + vscode.ConfigurationTarget.Workspace + ); + } } + + lintDiagnostics = vscode.languages.createDiagnosticCollection('sourcemeta-studio-lint'); + metaschemaDiagnostics = vscode.languages.createDiagnosticCollection('sourcemeta-studio-metaschema'); + context.subscriptions.push(lintDiagnostics, metaschemaDiagnostics); + + if (vscode.window.activeTextEditor) { + lastActiveTextEditor = vscode.window.activeTextEditor; + } + + context.subscriptions.push( + vscode.commands.registerCommand('sourcemeta-studio.openPanel', () => { + webviewReady = false; + createOrRevealPanel(context); + updatePanelContent(); + }), + vscode.commands.registerCommand('sourcemeta-studio.isWebviewReady', () => webviewReady), + vscode.window.onDidChangeActiveTextEditor(handleActiveEditorChange), + vscode.workspace.onDidSaveTextDocument(handleDocumentSave) + ); +} + +export function deactivate(): void { + panel?.dispose(); + lintDiagnostics?.dispose(); + metaschemaDiagnostics?.dispose(); } diff --git a/vscode/src/panel/PanelManager.ts b/vscode/src/panel/PanelManager.ts deleted file mode 100644 index 5b2bc25..0000000 --- a/vscode/src/panel/PanelManager.ts +++ /dev/null @@ -1,146 +0,0 @@ -import * as vscode from 'vscode'; -import * as path from 'path'; -import * as fs from 'fs'; -import { PanelState, WebviewToExtensionMessage, ExtensionToWebviewMessage } from '../../../protocol/types'; - -/** - * Manages the webview panel lifecycle and content - */ -export class PanelManager { - private panel: vscode.WebviewPanel | undefined; - private readonly iconPath: vscode.Uri; - private readonly extensionPath: string; - private messageHandler?: (message: WebviewToExtensionMessage) => void; - private disposeHandler?: () => void; - - constructor(extensionPath: string) { - this.extensionPath = extensionPath; - this.iconPath = vscode.Uri.file(path.join(extensionPath, 'logo.png')); - } - - /** - * Set the message handler for webview messages - */ - setMessageHandler(handler: (message: WebviewToExtensionMessage) => void): void { - this.messageHandler = handler; - } - - setDisposeHandler(handler: () => void): void { - this.disposeHandler = handler; - } - - /** - * Create or reveal the panel - */ - createOrReveal(context: vscode.ExtensionContext): vscode.WebviewPanel { - const columnToShowIn = vscode.window.activeTextEditor - ? vscode.ViewColumn.Beside - : vscode.ViewColumn.One; - - if (this.panel) { - this.panel.reveal(columnToShowIn, true); - return this.panel; - } - - this.panel = vscode.window.createWebviewPanel( - 'sourcemetaStudio', - 'Sourcemeta Studio', - { - viewColumn: columnToShowIn, - preserveFocus: false - }, - { - enableScripts: true, - retainContextWhenHidden: true, - localResourceRoots: [ - vscode.Uri.file(this.extensionPath), - vscode.Uri.file(path.join(this.extensionPath, '..', 'build', 'webview')) - ] - } - ); - - this.panel.iconPath = this.iconPath; - - // Set initial HTML content - this.panel.webview.html = this.getHtmlContent(this.panel.webview); - - // Handle messages from the webview - this.panel.webview.onDidReceiveMessage( - message => { - if (this.messageHandler) { - this.messageHandler(message); - } - }, - undefined, - context.subscriptions - ); - - this.panel.onDidDispose( - () => { - this.panel = undefined; - if (this.disposeHandler) { - this.disposeHandler(); - } - }, - null, - context.subscriptions - ); - - return this.panel; - } - - /** - * Update the panel content (send state to React) - */ - updateContent(state: PanelState): void { - if (!this.panel) { - return; - } - - const message: ExtensionToWebviewMessage = { - type: 'update', - state: state - }; - this.panel.webview.postMessage(message); - } - - /** - * Get HTML content for the webview (load React build) - */ - private getHtmlContent(_webview: vscode.Webview): string { - // Try production path first (inside extension directory) - const productionPath = path.join(this.extensionPath, 'index.html'); - - if (fs.existsSync(productionPath)) { - return fs.readFileSync(productionPath, 'utf-8'); - } - - // Fall back to development path (outside extension directory) - const devPath = path.join(this.extensionPath, '..', 'build', 'webview', 'index.html'); - return fs.readFileSync(devPath, 'utf-8'); - } - - /** - * Check if panel exists - */ - exists(): boolean { - return this.panel !== undefined; - } - - /** - * Get the panel (if it exists) - */ - getPanel(): vscode.WebviewPanel | undefined { - return this.panel; - } - - /** - * Dispose the panel - */ - dispose(): void { - if (this.panel) { - this.panel.dispose(); - this.panel = undefined; - } - } -} diff --git a/vscode/src/types.ts b/vscode/src/types.ts deleted file mode 100644 index e993ab8..0000000 --- a/vscode/src/types.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * VSCode extension-specific types - */ - -export const DiagnosticType = { - Lint: 'lint', - Metaschema: 'metaschema' -} as const; - -export type DiagnosticType = typeof DiagnosticType[keyof typeof DiagnosticType]; diff --git a/vscode/src/utils/fileUtils.ts b/vscode/src/utils/fileUtils.ts deleted file mode 100644 index 8438891..0000000 --- a/vscode/src/utils/fileUtils.ts +++ /dev/null @@ -1,288 +0,0 @@ -import * as path from 'path'; -import * as vscode from 'vscode'; -import * as fs from 'fs'; -import { FileInfo, LintResult, MetaschemaResult, CliError, Position } from '../../../protocol/types'; - -/** - * Parse generic CLI error response from JSON output - */ -export function parseCliError(output: string): CliError | null { - try { - const parsed = JSON.parse(output); - if (parsed.error && typeof parsed.error === 'string') { - return { - error: parsed.error, - line: parsed.line, - column: parsed.column, - filePath: parsed.filePath, - identifier: parsed.identifier, - location: parsed.location, - rule: parsed.rule, - testNumber: parsed.testNumber, - uri: parsed.uri, - command: parsed.command, - option: parsed.option - }; - } - } catch { - // Not JSON or doesn't have error field - } - return null; -} - -/** - * Check if there are JSON parse errors in lint or metaschema results - */ -export function hasJsonParseErrors(lintResult: LintResult, metaschemaResult: MetaschemaResult): boolean { - if (lintResult.errors && lintResult.errors.length > 0) { - const hasLintParseError = lintResult.errors.some(error => - error.id === 'json-parse-error' || - error.message.toLowerCase().includes('failed to parse') - ); - if (hasLintParseError) { - return true; - } - } - - if (metaschemaResult.errors && metaschemaResult.errors.length > 0) { - const hasMetaschemaParseError = metaschemaResult.errors.some(error => - error.error.toLowerCase().includes('failed to parse') - ); - if (hasMetaschemaParseError) { - return true; - } - } - - return false; -} - -/** - * Get information about a file path - */ -export function getFileInfo(filePath: string | undefined): FileInfo | null { - if (!filePath) { - return null; - } - - // Check if file is JSON or YAML - const extension = path.extname(filePath).toLowerCase(); - const isValidFile = ['.json', '.yaml', '.yml'].includes(extension); - - if (!isValidFile) { - return null; - } - - // Get relative path if workspace folder exists - const workspaceFolders = vscode.workspace.workspaceFolders; - let displayPath = filePath; - - if (workspaceFolders && workspaceFolders.length > 0) { - const firstFolder = workspaceFolders[0]; - if (firstFolder !== undefined) { - const workspaceRoot = firstFolder.uri.fsPath; - if (filePath.startsWith(workspaceRoot)) { - displayPath = path.relative(workspaceRoot, filePath); - } - } - } - - let lineCount = 0; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - lineCount = content.split('\n').length; - } catch (error) { - console.error('Failed to read file for line count:', error); - } - - const isYaml = extension === '.yaml' || extension === '.yml'; - - return { - absolutePath: filePath, - displayPath: displayPath, - fileName: path.basename(filePath), - lineCount, - isYaml - }; -} - -/** - * Parse lint command output - */ -export function parseLintResult(lintOutput: string): LintResult { - try { - const parsed = JSON.parse(lintOutput); - - if (parsed.error && typeof parsed.error === 'string' && - typeof parsed.line === 'number' && typeof parsed.column === 'number' && - parsed.filePath && !parsed.identifier) { - - const description = `Failed to parse JSON document at line ${parsed.line}, column ${parsed.column}`; - - return { - raw: lintOutput, - health: 0, - valid: false, - errors: [{ - id: 'json-parse-error', - message: parsed.error, - description: description, - path: '/', - schemaLocation: '/', - position: [parsed.line, parsed.column, parsed.line, parsed.column] - }] - }; - } - - if (parsed.error && !parsed.health && !Array.isArray(parsed.errors)) { - const hasPosition = typeof parsed.line === 'number' && typeof parsed.column === 'number'; - let description = parsed.error; - - if (parsed.filePath) { - description = `Error in ${parsed.filePath}`; - if (hasPosition) { - description += ` at line ${parsed.line}, column ${parsed.column}`; - } - } - - return { - raw: lintOutput, - health: 0, - valid: false, - errors: [{ - id: parsed.identifier ? 'cli-error-with-id' : 'cli-error', - message: parsed.error, - description: description, - path: parsed.location || '/', - schemaLocation: parsed.identifier || '/', - position: hasPosition ? [parsed.line, parsed.column, parsed.line, parsed.column] : null - }] - }; - } - - // Normal lint response format - return { - raw: lintOutput, - health: parsed.health, - valid: parsed.valid, - errors: parsed.errors || [] - }; - } catch (error) { - console.error('Failed to parse lint result:', error instanceof Error ? error.message : String(error)); - return { - raw: lintOutput, - health: null, - error: true - }; - } -} - -/** - * Parse metaschema command output - */ -export function parseMetaschemaResult(output: string, exitCode: number | null): MetaschemaResult { - const result: MetaschemaResult = { output, exitCode }; - - if (exitCode === 1) { - const cliError = parseCliError(output); - if (cliError) { - result.errors = [{ - error: cliError.error, - instanceLocation: cliError.location || '/', - keywordLocation: '/', - absoluteKeywordLocation: cliError.identifier, - instancePosition: cliError.line && cliError.column - ? [cliError.line, cliError.column, cliError.line, cliError.column] - : undefined - }]; - return result; - } - } - - if (exitCode === 2) { - try { - let jsonStr = output.trim(); - - const jsonStart = jsonStr.indexOf('['); - const jsonEnd = jsonStr.lastIndexOf(']'); - - if (jsonStart !== -1 && jsonEnd !== -1 && jsonStart < jsonEnd) { - jsonStr = jsonStr.substring(jsonStart, jsonEnd + 1); - } - - const parsed = JSON.parse(jsonStr); - if (Array.isArray(parsed)) { - result.errors = parsed.map((error: { - error?: string; - instanceLocation?: string; - keywordLocation?: string; - absoluteKeywordLocation?: string; - instancePosition?: Position; - }) => ({ - error: error.error || 'Validation error', - instanceLocation: error.instanceLocation || '', - keywordLocation: error.keywordLocation || '', - absoluteKeywordLocation: error.absoluteKeywordLocation, - instancePosition: error.instancePosition - })); - console.log('[Metaschema] Mapped errors count:', result.errors.length); - } else { - console.error('[Metaschema] Expected array but got:', typeof parsed); - } - } catch (error) { - console.error('Failed to parse metaschema result:', error instanceof Error ? error.message : String(error)); - console.error('[Metaschema] Raw output:', output); - console.error('[Metaschema] Output length:', output.length); - } - } - - return result; -} - -/** - * Escape HTML special characters - */ -export function escapeHtml(text: string): string { - return text.replace(//g, '>'); -} - -/** - * Convert VS Code position to 1-based array format - */ -export function positionToArray(position: vscode.Position): [number, number] { - return [position.line + 1, position.character + 1]; -} - -/** - * Convert 1-based array format to VS Code position - */ -export function arrayToPosition(arr: [number, number]): vscode.Position { - return new vscode.Position(arr[0] - 1, arr[1] - 1); -} - -/** - * Convert error position array to VS Code range - * Position array is 1-based and inclusive, VS Code is 0-based and end-exclusive - * - * When a diagnostic applies to the root of the document (position spanning - * from line 1, column 1 across multiple lines, or across a single line in - * minified files), we collapse the range to - * a zero-width range at (0,0). VS Code renders this by expanding the - * squiggle to the first word, matching what ESLint and Pylint do. VS Code - * does not support file-level diagnostics without a range: - * https://github.com/microsoft/vscode/issues/238608 - */ -export function errorPositionToRange(position: Position): vscode.Range { - const [lineStart, columnStart, lineEnd, columnEnd] = position; - - if (lineStart === 1 && columnStart === 1 && (lineEnd > lineStart || columnEnd > columnStart)) { - return new vscode.Range( - new vscode.Position(0, 0), - new vscode.Position(0, 0) - ); - } - - return new vscode.Range( - new vscode.Position(lineStart - 1, columnStart - 1), - new vscode.Position(lineEnd - 1, columnEnd) - ); -}