diff --git a/src/desktop/extension.ts b/src/desktop/extension.ts index b722152d..a9f7f11a 100644 --- a/src/desktop/extension.ts +++ b/src/desktop/extension.ts @@ -82,6 +82,7 @@ import { CsolutionService } from '../json-rpc/csolution-rpc-client'; import { BuildStopCommand } from '../tasks/build/build-stop-command'; import { ComponentsPacksWebviewMain } from '../views/manage-components-packs/components-packs-webview-main'; import { SolutionConverterImpl } from '../solutions/solution-converter'; +import { SolutionProblemsImpl } from '../solutions/solution-problems'; import { EnvironmentManager } from './env-manager'; import { ExtensionApiWrapper } from '../vscode-api/extension-api-wrapper'; import { SerialMonitorApi, Version } from '@microsoft/vscode-serial-monitor-api'; @@ -178,6 +179,7 @@ export const activate = async (context: ExtensionContext): Promise jest.fn((cmd) => Promise.resolve(path.join('path', 'to', cmd)))); @@ -72,6 +72,7 @@ describe('SolutionConverter', () => { let mockCsolutionService: jest.Mocked>; let convertRequestData: ConvertRequestData; let completedListener: jest.Mock; + let solutionProblems: SolutionProblemsImpl; /** * Helper to wait for N completion events from EventHub @@ -143,9 +144,11 @@ describe('SolutionConverter', () => { cmsisToolboxManager, compileCommandsGenerator, ); + solutionProblems = new SolutionProblemsImpl(solutionManager, eventHub); initUtils(mockConfigurationProvider, solutionManager); converter.activate({ subscriptions: [] } as unknown as ExtensionContext); + await solutionProblems.activate({ subscriptions: [] } as unknown as ExtensionContext); mockCsolutionService.listMissingPacks.mockResolvedValue({ success: true }); mockCsolutionService.convertSolution.mockResolvedValue({ success: true }); @@ -304,9 +307,6 @@ describe('SolutionConverter', () => { }); it('get log messages and set diagnostics accordingly', async () => { - const mockDiagnosticsCollectionSet = jest.spyOn(vscode.languages.createDiagnosticCollection(), 'set'); - const mockDiagnosticsCollectionClear = jest.spyOn(vscode.languages.createDiagnosticCollection(), 'clear'); - mockCsolutionService.convertSolution.mockResolvedValue({ success: false }); mockCsolutionService.getLogMessages.mockResolvedValue({ success: true, @@ -316,13 +316,10 @@ describe('SolutionConverter', () => { }); await fireAndWaitForConversion(); - expect(mockDiagnosticsCollectionClear).toHaveBeenCalled(); - expect(mockDiagnosticsCollectionSet).toHaveBeenCalledTimes(3); expect(completedListener).toHaveBeenCalledTimes(1); }); it('get cbuild west output and set diagnostics accordingly', async () => { - const mockDiagnosticsCollectionSet = jest.spyOn(vscode.languages.createDiagnosticCollection(), 'set'); mockCsolutionService.convertSolution.mockResolvedValue({ success: true }); mockCsolutionService.getLogMessages.mockResolvedValue({ success: true }); let mockRunCbuildSetup = jest.spyOn(compileCommandsGenerator, 'runCbuildSetup').mockResolvedValue([true, [ @@ -336,15 +333,23 @@ describe('SolutionConverter', () => { jest.spyOn(vscodeUtils, 'getWorkspaceFolder').mockReturnValue('workspace/folder'); await fireAndWaitForConversion(); + await waitTimeout(); expect(mockRunCbuildSetup).toHaveBeenCalledTimes(1); - expect(mockDiagnosticsCollectionSet).toHaveBeenCalledTimes(2); expect(completedListener).toHaveBeenCalledTimes(1); + expect(completedListener).toHaveBeenLastCalledWith( + expect.objectContaining({ + severity: 'error', + toolsOutputMessages: expect.arrayContaining([ + 'warning cbuild: missing ZEPHYR_BASE environment variable', + 'error cbuild: exec: "west": executable file not found in $PATH', + ]), + }), + ); // Remove settings.json completedListener.mockClear(); const settings = path.join(getWorkspaceFolder(), '.vscode', 'settings.json'); mockRunCbuildSetup.mockClear(); - mockDiagnosticsCollectionSet.mockClear(); fsUtils.deleteFileIfExists(settings); await fireAndWaitForConversion(); expect(mockRunCbuildSetup).toHaveBeenCalledTimes(1); @@ -354,7 +359,6 @@ describe('SolutionConverter', () => { completedListener.mockClear(); mockRunCbuildSetup = jest.spyOn(compileCommandsGenerator, 'runCbuildSetup').mockResolvedValue([true, []]); mockRunCbuildSetup.mockClear(); - mockDiagnosticsCollectionSet.mockClear(); await fireAndWaitForConversion(); expect(mockRunCbuildSetup).toHaveBeenCalledTimes(1); expect(completedListener).toHaveBeenCalledTimes(1); @@ -425,11 +429,7 @@ describe('SolutionConverter', () => { }); }); - it('creates diagnostic when cpackget fails to download a pack', async () => { - const diagnosticCollection = vscode.languages.createDiagnosticCollection(); - const mockDiagnosticsCollectionSet = jest.spyOn(diagnosticCollection, 'set') as unknown as jest.MockedFunction< - (uri: vscode.Uri, diagnostics: readonly vscode.Diagnostic[] | undefined) => void - >; + it('reports error severity when cpackget fails to download a pack', async () => { jest.spyOn(cmsisToolboxManager, 'runCmsisTool').mockImplementation(async (_t, _a, onOutput) => { onOutput('W: retry failed'); onOutput('E: network timeout'); @@ -440,24 +440,16 @@ describe('SolutionConverter', () => { await fireAndWaitForConversion(); - expect(mockDiagnosticsCollectionSet).toHaveBeenCalledTimes(1); - const [[, diagnostics]] = mockDiagnosticsCollectionSet.mock.calls; - expect(diagnostics?.[0]?.message).toContain('network timeout'); - expect(diagnostics?.[0]?.message).toContain('retry failed'); + expect(completedListener).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'error', + toolsOutputMessages: expect.arrayContaining([ + expect.stringContaining("error cpackget: downloading pack 'VendorA::PackA@1.0.0' failed"), + expect.stringContaining('network timeout'), + expect.stringContaining('retry failed'), + ]), + }) + ); }); - it('extracts warnings from cbuild2cmake and csolution tool output', async () => { - const mockDiagnosticsCollectionSet = jest.spyOn(vscode.languages.createDiagnosticCollection(), 'set'); - mockCsolutionService.convertSolution.mockResolvedValue({ success: true }); - mockCsolutionService.getLogMessages.mockResolvedValue({ success: true }); - jest.spyOn(compileCommandsGenerator, 'runCbuildSetup').mockResolvedValue([true, [ - 'warning cbuild2cmake: some warning', - 'error csolution: some error', - ]]); - - await fireAndWaitForConversion(); - - // Expect two calls: one for cbuild2cmake warning, one for csolution error - expect(mockDiagnosticsCollectionSet).toHaveBeenCalledTimes(2); - }); }); diff --git a/src/solutions/solution-converter.ts b/src/solutions/solution-converter.ts index c02940d0..c745aefa 100644 --- a/src/solutions/solution-converter.ts +++ b/src/solutions/solution-converter.ts @@ -15,8 +15,6 @@ */ import { ExtensionContext } from 'vscode'; -import * as vscode from 'vscode'; -import path from 'node:path'; import * as manifest from '../manifest'; import { ConfigurationProvider } from '../vscode-api/configuration-provider'; import { OutputChannelProvider } from '../vscode-api/output-channel-provider'; @@ -25,12 +23,9 @@ import { SolutionManager } from './solution-manager'; import { CompileCommandsGenerator } from './intellisense/compile-commands-generator'; import { Mutex } from 'async-mutex'; import * as rpc from '../json-rpc/csolution-rpc-client'; -import * as fsUtils from '../utils/fs-utils'; -import { getFileNameFromPath } from '../utils/path-utils'; -import { stripTwoExtensions } from '../utils/string-utils'; -import { getWorkspaceFolder } from '../utils/vscode-utils'; import { ConvertRequestData, SolutionEventHub } from './solution-event-hub'; import { Severity } from './constants'; +import { toolsPrefixPatterns } from './solution-problems'; export interface SolutionConverter { @@ -42,7 +37,6 @@ export class SolutionConverterImpl implements SolutionConverter { private readonly convertSolutionMutex = new Mutex(); private controller: AbortController | null = null; private data: ConvertRequestData = { solutionPath: '', targetSet: '', updateRte: false, restartRpc: false }; - private readonly diagnosticCollection: vscode.DiagnosticCollection = vscode.languages.createDiagnosticCollection('csolution'); constructor( private readonly solutionManager: SolutionManager, @@ -57,7 +51,6 @@ export class SolutionConverterImpl implements SolutionConverter { public activate(context: ExtensionContext) { context.subscriptions.push( this.eventHub.onDidConvertRequested(this.handleConvertRequested, this), - this.diagnosticCollection, ); } @@ -194,11 +187,8 @@ export class SolutionConverterImpl implements SolutionConverter { if (signal.aborted) { return; } - // update 'problems' view logResult = { errors: [], warnings: [], info: [], ...logResult, success: convertResult.success }; - const severity = await this.updateDiagnostics(logResult, toolsOutputMessages); - - csolution?.setLogMessages(logResult); + const severity = this.getSeverity(logResult, toolsOutputMessages); // print result to output channel outputChannel.append('\n' + ( @@ -211,7 +201,12 @@ export class SolutionConverterImpl implements SolutionConverter { '✅ Convert solution completed' ) + '\n\n'); // notify conversion result and detection status asynchronously! - this.eventHub.fireConvertCompleted({ severity: severity, detection: detection }); + this.eventHub.fireConvertCompleted({ + severity: severity, + detection: detection, + logMessages: logResult, + toolsOutputMessages, + }); } private async printErrorsWarnings(messages?: rpc.LogMessages): Promise { @@ -265,265 +260,14 @@ export class SolutionConverterImpl implements SolutionConverter { return result.success; } - /** - * log message regex in the format :: - - * regex named groups: - * filename: optional file path - * line: optional line number (digits) - * column: optional column number (digits) - * message: the actual diagnostic message (may span multiple lines) - */ - private readonly logMessageRegex = /^(?:(?(?:[A-Za-z]:)?[^\r\n:]*?[^\s\r\n:])\s*(?::\s*(?\d+))?(?::\s*(?\d+))?\s*-\s+)?(?[\s\S]*)$/; - - private async addDiagnosticEntry(message: string, severity: vscode.DiagnosticSeverity, files: Map): Promise { - // skip excluded messages - if (this.isMessageExcluded(message)) { - return false; - } - // parse message according to logMessageRegex - const m = message.match(this.logMessageRegex); - if (m?.groups) { - const { filename, line, column, message } = m.groups; - const file = (files.has(filename) ? files.get(filename) : this.data?.solutionPath) ?? ''; - const startLine = line ? Number(line) - 1 : 0; - const startCharacter = column ? Number(column) - 1 : 0; - let endCharacter = startCharacter; - if (filename && column) { - const doc = await vscode.workspace.openTextDocument(file); - if (doc) { - endCharacter = doc.lineAt(startLine).range.end.character; - } - } - const range = new vscode.Range(startLine, startCharacter, startLine, endCharacter); - const entry = new vscode.Diagnostic(range, message, severity); - entry.source = 'csolution'; - - if (!line && !column) { - // add 'Find in Files' action only if no line/column info is available - const args = this.createQueryArgs(message); - if (args) { - entry.code = { - value: 'Find in Files', - target: vscode.Uri.parse(`command:workbench.action.findInFiles?${args}`) - }; - } - } - - // append diagnostic entry - const uri = vscode.Uri.file(path.posix.normalize(file)); - this.diagnosticCollection.set(uri, [...(this.diagnosticCollection.get(uri) ?? []), entry]); - return true; - } - return false; - } - - private async updateDiagnostics(messages: rpc.LogMessages, toolsOutputMessages?: string[]): Promise { - // clear previous diagnostics - this.diagnosticCollection.clear(); - this.collectYmlFiles(); - let diagnostics = false; - - // extract messages from tools output - await this.collectToolsMessages(messages, toolsOutputMessages); - - // iterate through log messages and set diagnostics - for (const message of messages.errors ?? []) { - diagnostics = await this.addDiagnosticEntry(message, vscode.DiagnosticSeverity.Error, this.sourceFiles) || diagnostics; - } - for (const message of messages.warnings ?? []) { - diagnostics = await this.addDiagnosticEntry(message, vscode.DiagnosticSeverity.Warning, this.sourceFiles) || diagnostics; - } - for (const message of messages.info ?? []) { - diagnostics = await this.addDiagnosticEntry(message, vscode.DiagnosticSeverity.Information, this.sourceFiles) || diagnostics; - } - if (diagnostics) { - vscode.commands.executeCommand('workbench.actions.view.problems', { preserveFocus: true }); - } - // return overall severity - if (!messages.success || (messages.errors && messages.errors.length > 0)) { + private getSeverity(messages: rpc.LogMessages, lines?: string[]): Severity { + if (!messages.success || (messages.errors && messages.errors.length > 0) || lines?.find(line => toolsPrefixPatterns.error.test(line))) { return 'error'; - } else if (messages.warnings && messages.warnings.length > 0) { + } else if ((messages.warnings && messages.warnings.length > 0) || lines?.find(line => toolsPrefixPatterns.warning.test(line))) { return 'warning'; } else if (messages.info && messages.info.length > 0) { return 'info'; } return 'success'; } - - /** - * source files for diagnostics mapping - */ - private readonly sourceFiles: Map = new Map(); - - private addFile(file: string): void { - if (file.length > 0) { - this.sourceFiles.set(getFileNameFromPath(file), file); - } - } - - private collectYmlFiles(): void { - // collect relevant yml files for diagnostics mapping - this.sourceFiles.clear(); - const csolution = this.solutionManager.getCsolution(); - if (csolution) { - const activeSolution = csolution.solutionPath ?? ''; - // get yml files located alongside the active solution and cbuild-idx file - this.addFile(activeSolution); - this.addFile(csolution.cbuildIdxFile.fileName); - this.addFile(csolution.cbuildRunYml?.fileName ?? ''); - const strippedSolution = stripTwoExtensions(activeSolution); - this.addFile(strippedSolution + '.cbuild-pack.yml'); - this.addFile(strippedSolution + '.cbuild-set.yml'); - // get cproject.yml and clayer.yml files from all contexts - const contexts = csolution.cbuildIdxFile.activeContexts; - for (const context of contexts ?? []) { - if (context.projectPath) { - this.addFile(context.projectPath); - } - for (const layer of context.layers ?? []) { - this.addFile(layer.absolutePath); - } - } - // get all cbuild.yml files - const cbuilds = csolution.cbuildIdxFile.cbuildFiles; - for (const [, cbuild] of cbuilds) { - this.addFile(cbuild.fileName); - } - } - } - - /** - * patterns for non relevant log messages to be excluded from diagnostics - */ - private readonly excludePatterns = [ - /processing context .* failed/, - /file is already up-to-date/, - /file generated successfully/, - /file skipped/, - ]; - - private isMessageExcluded(message: string): boolean { - // exclude non relevant messages - return this.excludePatterns.some(pattern => pattern.test(message)); - } - - /** - * patterns to extract query from log message for 'Find in Files' action - */ - private readonly queryPatterns = [ - /(?:MISSING|SELECTABLE)\s+(.*)/, // component dependency - /\/([^/\s']+\.[^/\s']+)/, // capture filename from path - /'([^']+)'/, // single quotes - /([A-Za-z0-9_.-]+::[A-Za-z0-9_.-]+(@[A-Za-z0-9_.-]+)*)/, // pack/component identifier - /([A-Za-z0-9_.-]+@[A-Za-z0-9_.-]*)/, // compiler/tool identifier - ]; - - private createQueryArgs(message: string): string | undefined { - // empirically find possible query patterns - let query; - for (const pattern of this.queryPatterns) { - const match = message.match(pattern); - if (match) { - query = match[1]; - break; - } - } - if (!query) { - return undefined; - } - const args = { - query: query, - filesToInclude: '*.yml,*.yaml', // limit search to yml files - filesToExclude: '*.cbuild-idx.yml,*.cbuild.yml,*.cbuild-run.yml', // exclude generated or intermediate files - isRegex: false, - isCaseSensitive: false, - matchWholeWord: false, - triggerSearch: true, - focusResults: true, - }; - return encodeURIComponent(JSON.stringify(args)); - } - - /** - * patterns to extract errors and warnings from tools messages - */ - private readonly toolsPrefixPatterns = { - error: /^.*error (?:cbuild|cbuild2cmake|csolution|cpackget):\s*/, - warning: /^.*warning (?:cbuild|cbuild2cmake|csolution|cpackget):\s*/, - }; - - private pushUniquely(array: string[], value: string) { - if (!array.includes(value)) { - array.push(value); - } - } - - private async collectToolsMessages(logMessages: rpc.LogMessages, lines?: string[]): Promise { - if (lines) { - let errors = lines.filter(line => - this.toolsPrefixPatterns.error.test(line) - ); - let warnings = lines.filter(line => - this.toolsPrefixPatterns.warning.test(line) - ); - if (warnings.length || errors.length) { - // remove tool-specific prefixes from messages - const sanitize = (m: string, kind: 'error' | 'warning') => m.replace(this.toolsPrefixPatterns[kind], '').trim(); - errors = errors.map(e => sanitize(e, 'error')); - warnings = warnings.map(w => sanitize(w, 'warning')); - // format west related messages if any - await this.formatWestMessages(errors, warnings); - // append messages to logMessages - errors.forEach(e => this.pushUniquely(logMessages.errors ?? [], e)); - warnings.forEach(w => this.pushUniquely(logMessages.warnings ?? [], w)); - } - } - } - - /** - * patterns to extract environment variables and west warnings and errors - */ - private readonly envVarWestPatterns = [ - /^missing ([A-Za-z_][A-Za-z0-9_]*) environment variable$/, - /^([A-Za-z_][A-Za-z0-9_]*) environment variable specifies non-existent directory: .+$/, - /^exec: "west": executable file not found in .+$/, - ]; - - private async formatWestMessages(errors: string[], warnings: string[]): Promise { - // extract warnings and errors around environment variables and west settings - const hasWestMessages = [...errors, ...warnings].some(line => - this.envVarWestPatterns.some(pattern => pattern.test(line)) - ); - if (!hasWestMessages) { - return; - } - const workspaceFolder = getWorkspaceFolder(); - if (!workspaceFolder) { - return; - } - // find cmsis-csolution.environmentVariables location in workspace file or settings.json - const settings = vscode.workspace.workspaceFile?.fsPath ?? path.join(workspaceFolder, '.vscode', 'settings.json'); - const settingsName = getFileNameFromPath(settings); - const envvars = '"cmsis-csolution.environmentVariables"'; - let startPos: vscode.Position | undefined; - if (fsUtils.fileExists(settings)) { - const doc = await vscode.workspace.openTextDocument(settings); - const startOffset = doc.getText().indexOf(envvars); - if (startOffset >= 0) { - startPos = doc.positionAt(startOffset); - } - } - const location = startPos ? `:${startPos.line + 1}:${startPos.character + 1}` : ''; - // format messages - const format = (items: string[]) => { - for (let i = 0; i < items.length; i++) { - if (this.envVarWestPatterns.some(pattern => pattern.test(items[i]))) { - items[i] = `${settingsName}${location} - ${items[i]}; review ${envvars}`; - } - } - }; - format(errors); - format(warnings); - this.addFile(settings); - } } diff --git a/src/solutions/solution-event-hub.test.ts b/src/solutions/solution-event-hub.test.ts index 236fac67..c65cfa0b 100644 --- a/src/solutions/solution-event-hub.test.ts +++ b/src/solutions/solution-event-hub.test.ts @@ -21,6 +21,7 @@ import { Severity } from './constants'; describe('EventHub', () => { let eventHub: SolutionEventHub; let mockContext: vscode.ExtensionContext; + const logMessages = { success: true, errors: [], warnings: [], info: [] }; beforeEach(() => { eventHub = new SolutionEventHub(); @@ -96,7 +97,7 @@ describe('EventHub', () => { const listener = jest.fn(); eventHub.onDidConvertCompleted(listener); - const data: ConvertResultData = { severity, detection }; + const data: ConvertResultData = { severity, detection, logMessages }; await eventHub.fireConvertCompleted(data); expect(listener).toHaveBeenCalledTimes(1); @@ -109,7 +110,7 @@ describe('EventHub', () => { eventHub.onDidConvertCompleted(listener1); eventHub.onDidConvertCompleted(listener2); - const data: ConvertResultData = { severity: 'success', detection: true }; + const data: ConvertResultData = { severity: 'success', detection: true, logMessages }; await eventHub.fireConvertCompleted(data); expect(listener1).toHaveBeenCalledWith(data); @@ -134,7 +135,7 @@ describe('EventHub', () => { expect(requestListener).toHaveBeenCalledTimes(1); expect(completeListener).not.toHaveBeenCalled(); - await eventHub.fireConvertCompleted({ severity: 'success', detection: true }); + await eventHub.fireConvertCompleted({ severity: 'success', detection: true, logMessages }); expect(requestListener).toHaveBeenCalledTimes(1); expect(completeListener).toHaveBeenCalledTimes(1); @@ -170,8 +171,8 @@ describe('EventHub', () => { eventHub.onDidConvertCompleted(listener); await Promise.all([ - eventHub.fireConvertCompleted({ severity: 'info', detection: true }), - eventHub.fireConvertCompleted({ severity: 'error', detection: false }) + eventHub.fireConvertCompleted({ severity: 'info', detection: true, logMessages }), + eventHub.fireConvertCompleted({ severity: 'error', detection: false, logMessages }) ]); expect(listener).toHaveBeenCalledTimes(2); diff --git a/src/solutions/solution-event-hub.ts b/src/solutions/solution-event-hub.ts index 64ccc844..5adbb687 100644 --- a/src/solutions/solution-event-hub.ts +++ b/src/solutions/solution-event-hub.ts @@ -15,6 +15,7 @@ */ import * as vscode from 'vscode'; import { constructor } from '../generic/constructor'; +import { LogMessages } from '../json-rpc/csolution-rpc-client'; import { Severity } from './constants'; /** @@ -34,6 +35,8 @@ export interface ConvertRequestData { export interface ConvertResultData { severity: Severity; detection: boolean; + logMessages: LogMessages; + toolsOutputMessages?: string[]; } /** diff --git a/src/solutions/solution-manager.test.ts b/src/solutions/solution-manager.test.ts index 54fb2cb0..20c16fdb 100644 --- a/src/solutions/solution-manager.test.ts +++ b/src/solutions/solution-manager.test.ts @@ -31,7 +31,11 @@ import { csolutionServiceFactory } from '../json-rpc/csolution-rpc-client.factor import { SolutionRpcData } from './solution-rpc-data'; -const convertResultData: ConvertResultData = { severity: 'success', detection: false }; +const convertResultData: ConvertResultData = { + severity: 'success', + detection: false, + logMessages: { success: true, errors: [], warnings: [], info: [] }, +}; describe('SolutionManager', () => { let mockActiveSolutionTracker: { diff --git a/src/solutions/solution-problems.test.ts b/src/solutions/solution-problems.test.ts new file mode 100644 index 00000000..ded609e5 --- /dev/null +++ b/src/solutions/solution-problems.test.ts @@ -0,0 +1,176 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect, beforeEach } from '@jest/globals'; +import * as vscode from 'vscode'; +import { ExtensionContext } from 'vscode'; +import * as fsUtils from '../utils/fs-utils'; +import * as vscodeUtils from '../utils/vscode-utils'; +import { solutionManagerFactory, MockSolutionManager } from './solution-manager.factories'; +import { SolutionEventHub } from './solution-event-hub'; +import { enrichLogMessagesFromToolOutput, SolutionProblemsImpl } from './solution-problems'; +import { waitTimeout } from '../__test__/test-waits'; + +const solutionPath = '/work/app.csolution.yml'; +const layerPath = '/work/config/mylayer.clayer.yml'; + +const buildCsolution = () => { + return { + solutionPath, + cbuildRunYml: undefined, + cbuildIdxFile: { + fileName: '/work/app.cbuild-idx.yml', + activeContexts: [{ + projectPath: '/work/project.cproject.yml', + layers: [{ absolutePath: layerPath }], + }], + cbuildFiles: new Map([ + ['ctx', { fileName: '/work/ctx.cbuild.yml' }], + ]), + }, + }; +}; + +describe('SolutionProblems', () => { + let solutionManager: MockSolutionManager; + let eventHub: SolutionEventHub; + let solutionProblems: SolutionProblemsImpl; + + beforeEach(() => { + solutionManager = solutionManagerFactory(); + solutionManager.getCsolution.mockReturnValue(buildCsolution() as unknown as ReturnType); + eventHub = new SolutionEventHub(); + solutionProblems = new SolutionProblemsImpl(solutionManager, eventHub); + + (vscode.workspace.openTextDocument as jest.Mock).mockResolvedValue({ + lineCount: 200, + lineAt: () => ({ range: { end: { character: 80 } } }), + }); + (vscode.commands.executeCommand as jest.Mock).mockClear(); + (vscode.languages.createDiagnosticCollection().set as jest.Mock).mockClear(); + (vscode.languages.createDiagnosticCollection().clear as jest.Mock).mockClear(); + }); + + it('registers listener and diagnostic collection on activate', async () => { + const context = { subscriptions: [] } as unknown as ExtensionContext; + + await solutionProblems.activate(context); + + expect(context.subscriptions).toHaveLength(2); + }); + + it('creates diagnostics from convert completed log messages', async () => { + await solutionProblems.activate({ subscriptions: [] } as unknown as ExtensionContext); + const setSpy = jest.spyOn(vscode.languages.createDiagnosticCollection(), 'set'); + const clearSpy = jest.spyOn(vscode.languages.createDiagnosticCollection(), 'clear'); + + await eventHub.fireConvertCompleted({ + severity: 'warning', + detection: false, + logMessages: { + success: true, + errors: ['mylayer.clayer.yml:10:2 - missing node'], + warnings: ['app.csolution.yml - unknown tool'], + info: ['general info message', 'app.cbuild-idx.yml - file generated successfully'], + }, + }); + await waitTimeout(); + + expect(clearSpy).toHaveBeenCalledTimes(1); + expect(setSpy).toHaveBeenCalledTimes(3); + expect(vscode.commands.executeCommand).toHaveBeenCalledWith('workbench.actions.view.problems', { preserveFocus: true }); + }); + + it('maps diagnostics to referenced source files from solution metadata', async () => { + await solutionProblems.activate({ subscriptions: [] } as unknown as ExtensionContext); + const setSpy = jest.spyOn(vscode.languages.createDiagnosticCollection(), 'set'); + + await eventHub.fireConvertCompleted({ + severity: 'error', + detection: false, + logMessages: { + success: false, + errors: ['mylayer.clayer.yml:2:1 - invalid value'], + warnings: [], + info: [], + }, + }); + await waitTimeout(); + + const [uri] = setSpy.mock.calls[0] as unknown as [vscode.Uri, readonly vscode.Diagnostic[] | undefined]; + expect(uri.fsPath).toContain('mylayer.clayer.yml'); + }); + + it('does not open problems view when all messages are excluded', async () => { + await solutionProblems.activate({ subscriptions: [] } as unknown as ExtensionContext); + const setSpy = jest.spyOn(vscode.languages.createDiagnosticCollection(), 'set'); + + await eventHub.fireConvertCompleted({ + severity: 'success', + detection: false, + logMessages: { + success: true, + errors: [], + warnings: [], + info: [ + 'hello.cbuild-idx.yml - file generated successfully', + 'foo.cbuild.yml - file is already up-to-date', + ], + }, + }); + await waitTimeout(); + + expect(setSpy).not.toHaveBeenCalled(); + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith('workbench.actions.view.problems', { preserveFocus: true }); + }); + + it('enriches prefixed tool messages and keeps them unique', async () => { + const messages = { success: true, errors: ['already there'], warnings: [], info: [] }; + + await enrichLogMessagesFromToolOutput(messages, [ + 'warning cbuild2cmake: generated warning', + 'warning cbuild2cmake: generated warning', + 'error csolution: generated error', + 'error csolution: generated error', + ]); + + expect(messages.errors).toEqual(['already there', 'generated error']); + expect(messages.warnings).toEqual(['generated warning']); + }); + + it('formats west-related messages with settings location', async () => { + jest.spyOn(vscodeUtils, 'getWorkspaceFolder').mockReturnValue('/workspace/folder'); + jest.spyOn(fsUtils, 'fileExists').mockReturnValue(true); + (vscode.workspace as { workspaceFile?: vscode.Uri }).workspaceFile = undefined; + (vscode.workspace.openTextDocument as jest.Mock).mockResolvedValue({ + getText: () => '{"cmsis-csolution.environmentVariables":{}}', + positionAt: () => ({ line: 2, character: 4 }), + lineCount: 100, + lineAt: () => ({ range: { end: { character: 80 } } }), + }); + + const messages = { success: true, errors: [], warnings: [], info: [] }; + await enrichLogMessagesFromToolOutput(messages, [ + 'warning cbuild: missing ZEPHYR_BASE environment variable', + 'error cbuild: exec: "west": executable file not found in $PATH', + ]); + + expect(messages.warnings[0]).toContain('.vscode'); + expect(messages.warnings[0]).toContain('settings.json:3:5 - missing ZEPHYR_BASE environment variable; review "cmsis-csolution.environmentVariables"'); + expect(messages.errors[0]).toContain('.vscode'); + expect(messages.errors[0]).toContain('settings.json:3:5 - exec: "west": executable file not found in $PATH; review "cmsis-csolution.environmentVariables"'); + }); +}); diff --git a/src/solutions/solution-problems.ts b/src/solutions/solution-problems.ts new file mode 100644 index 00000000..6119577d --- /dev/null +++ b/src/solutions/solution-problems.ts @@ -0,0 +1,306 @@ +/** + * Copyright 2026 Arm Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { constructor } from '../generic/constructor'; +import { LogMessages } from '../json-rpc/csolution-rpc-client'; +import * as fsUtils from '../utils/fs-utils'; +import { getFileNameFromPath } from '../utils/path-utils'; +import { stripTwoExtensions } from '../utils/string-utils'; +import { getWorkspaceFolder } from '../utils/vscode-utils'; +import { SolutionManager } from './solution-manager'; +import { ConvertResultData, SolutionEventHub } from './solution-event-hub'; + +export const toolsPrefixPatterns = { + error: /^.*error (?:cbuild|cbuild2cmake|csolution|cpackget):\s*/, + warning: /^.*warning (?:cbuild|cbuild2cmake|csolution|cpackget):\s*/, +}; + +export const envVarWestPatterns = [ + /^missing ([A-Za-z_][A-Za-z0-9_]*) environment variable$/, + /^([A-Za-z_][A-Za-z0-9_]*) environment variable specifies non-existent directory: .+$/, + /^exec: "west": executable file not found in .+$/, +]; + +const pushUniquely = (array: string[], value: string) => { + if (!array.includes(value)) { + array.push(value); + } +}; + +const formatWestMessages = async (errors: string[], warnings: string[]): Promise => { + const hasWestMessages = [...errors, ...warnings].some(line => + envVarWestPatterns.some(pattern => pattern.test(line)) + ); + if (!hasWestMessages) { + return; + } + const workspaceFolder = getWorkspaceFolder(); + if (!workspaceFolder) { + return; + } + + const settings = vscode.workspace.workspaceFile?.fsPath ?? path.join(workspaceFolder, '.vscode', 'settings.json'); + const envvars = '"cmsis-csolution.environmentVariables"'; + let startPos: vscode.Position | undefined; + if (fsUtils.fileExists(settings)) { + const doc = await vscode.workspace.openTextDocument(settings); + const startOffset = doc.getText().indexOf(envvars); + if (startOffset >= 0) { + startPos = doc.positionAt(startOffset); + } + } + const location = startPos ? `:${startPos.line + 1}:${startPos.character + 1}` : ''; + const format = (items: string[]) => { + for (let i = 0; i < items.length; i++) { + if (envVarWestPatterns.some(pattern => pattern.test(items[i]))) { + items[i] = `${settings}${location} - ${items[i]}; review ${envvars}`; + } + } + }; + format(errors); + format(warnings); +}; + +export const enrichLogMessagesFromToolOutput = async (logMessages: LogMessages, lines?: string[]): Promise => { + if (!lines) { + return; + } + + let errors = lines.filter(line => toolsPrefixPatterns.error.test(line)); + let warnings = lines.filter(line => toolsPrefixPatterns.warning.test(line)); + if (!warnings.length && !errors.length) { + return; + } + + const sanitize = (m: string, kind: 'error' | 'warning') => m.replace(toolsPrefixPatterns[kind], '').trim(); + errors = errors.map(e => sanitize(e, 'error')); + warnings = warnings.map(w => sanitize(w, 'warning')); + + await formatWestMessages(errors, warnings); + + const logErrors = logMessages.errors ?? (logMessages.errors = []); + const logWarnings = logMessages.warnings ?? (logMessages.warnings = []); + + errors.forEach(e => pushUniquely(logErrors, e)); + warnings.forEach(w => pushUniquely(logWarnings, w)); +}; + +export interface SolutionProblems { + activate(context: vscode.ExtensionContext): Promise; +} + +export class SolutionProblemsImpl implements SolutionProblems { + + private readonly diagnosticCollection: vscode.DiagnosticCollection = vscode.languages.createDiagnosticCollection('csolution'); + + constructor( + private readonly solutionManager: SolutionManager, + private readonly eventHub: SolutionEventHub, + ) { + } + + public async activate(context: vscode.ExtensionContext): Promise { + context.subscriptions.push( + this.eventHub.onDidConvertCompleted(this.handleConvertCompleted, this), + this.diagnosticCollection, + ); + } + + private async handleConvertCompleted(data: ConvertResultData): Promise { + await enrichLogMessagesFromToolOutput(data.logMessages, data.toolsOutputMessages); + await this.updateDiagnostics(data.logMessages); + } + + /** + * log message regex in the format :: - + * regex named groups: + * filename: optional file path + * line: optional line number (digits) + * column: optional column number (digits) + * message: the actual diagnostic message (may span multiple lines) + */ + private readonly logMessageRegex = /^(?:(?(?:[A-Za-z]:)?[^\r\n:]*?[^\s\r\n:])\s*(?::\s*(?\d+))?(?::\s*(?\d+))?\s*-\s+)?(?[\s\S]*)$/; + + private async addDiagnosticEntry(message: string, severity: vscode.DiagnosticSeverity, files: Map): Promise { + // skip excluded messages + if (this.isMessageExcluded(message)) { + return false; + } + // parse message according to logMessageRegex + const m = message.match(this.logMessageRegex); + if (m?.groups) { + const { filename, line, column, message } = m.groups; + const normalizedFilename = filename ? getFileNameFromPath(filename) : undefined; + const fromMap = (filename && files.get(filename)) || (normalizedFilename && files.get(normalizedFilename)); + const file = fromMap || (filename && path.isAbsolute(filename) ? filename : undefined) || this.solutionManager.getCsolution()?.solutionPath; + if (!file) { + return false; + } + const startLine = line ? Math.max(Number(line) - 1, 0) : 0; + const startCharacter = column ? Math.max(Number(column) - 1, 0) : 0; + let endCharacter = startCharacter; + if (filename && column) { + try { + const doc = await vscode.workspace.openTextDocument(file); + if (doc && startLine < doc.lineCount) { + endCharacter = doc.lineAt(startLine).range.end.character; + } + } catch { + // Keep default endCharacter when document cannot be opened. + } + } + const range = new vscode.Range(startLine, startCharacter, startLine, endCharacter); + const entry = new vscode.Diagnostic(range, message, severity); + entry.source = 'csolution'; + + if (!line && !column) { + // add 'Find in Files' action only if no line/column info is available + const args = this.createQueryArgs(message); + if (args) { + entry.code = { + value: 'Find in Files', + target: vscode.Uri.parse(`command:workbench.action.findInFiles?${args}`) + }; + } + } + + // append diagnostic entry + const uri = vscode.Uri.file(path.posix.normalize(file)); + this.diagnosticCollection.set(uri, [...(this.diagnosticCollection.get(uri) ?? []), entry]); + return true; + } + return false; + } + + private async updateDiagnostics(messages: LogMessages): Promise { + // clear previous diagnostics + this.diagnosticCollection.clear(); + this.collectYmlFiles(); + let diagnostics = false; + + // iterate through log messages and set diagnostics + for (const message of messages.errors ?? []) { + diagnostics = await this.addDiagnosticEntry(message, vscode.DiagnosticSeverity.Error, this.sourceFiles) || diagnostics; + } + for (const message of messages.warnings ?? []) { + diagnostics = await this.addDiagnosticEntry(message, vscode.DiagnosticSeverity.Warning, this.sourceFiles) || diagnostics; + } + for (const message of messages.info ?? []) { + diagnostics = await this.addDiagnosticEntry(message, vscode.DiagnosticSeverity.Information, this.sourceFiles) || diagnostics; + } + if (diagnostics) { + vscode.commands.executeCommand('workbench.actions.view.problems', { preserveFocus: true }); + } + } + + /** + * source files for diagnostics mapping + */ + private readonly sourceFiles: Map = new Map(); + + private addFile(file: string): void { + if (file.length > 0) { + this.sourceFiles.set(getFileNameFromPath(file), file); + } + } + + private collectYmlFiles(): void { + // collect relevant yml files for diagnostics mapping + this.sourceFiles.clear(); + const csolution = this.solutionManager.getCsolution(); + if (csolution) { + const activeSolution = csolution.solutionPath ?? ''; + // get yml files located alongside the active solution and cbuild-idx file + this.addFile(activeSolution); + this.addFile(csolution.cbuildIdxFile.fileName); + this.addFile(csolution.cbuildRunYml?.fileName ?? ''); + const strippedSolution = stripTwoExtensions(activeSolution); + this.addFile(strippedSolution + '.cbuild-pack.yml'); + this.addFile(strippedSolution + '.cbuild-set.yml'); + // get cproject.yml and clayer.yml files from all contexts + const contexts = csolution.cbuildIdxFile.activeContexts; + for (const context of contexts ?? []) { + if (context.projectPath) { + this.addFile(context.projectPath); + } + for (const layer of context.layers ?? []) { + this.addFile(layer.absolutePath); + } + } + // get all cbuild.yml files + const cbuilds = csolution.cbuildIdxFile.cbuildFiles; + for (const [, cbuild] of cbuilds) { + this.addFile(cbuild.fileName); + } + } + } + + /** + * patterns for non relevant log messages to be excluded from diagnostics + */ + private readonly excludePatterns = [ + /processing context .* failed/, + /file is already up-to-date/, + /file generated successfully/, + /file skipped/, + ]; + + private isMessageExcluded(message: string): boolean { + // exclude non relevant messages + return this.excludePatterns.some(pattern => pattern.test(message)); + } + + /** + * patterns to extract query from log message for 'Find in Files' action + */ + private readonly queryPatterns = [ + /(?:MISSING|SELECTABLE)\s+(.*)/, // component dependency + /\/([^/\s']+\.[^/\s']+)/, // capture filename from path + /'([^']+)'/, // single quotes + /([A-Za-z0-9_.-]+::[A-Za-z0-9_.-]+(@[A-Za-z0-9_.-]+)*)/, // pack/component identifier + /([A-Za-z0-9_.-]+@[A-Za-z0-9_.-]*)/, // compiler/tool identifier + ]; + + private createQueryArgs(message: string): string | undefined { + // empirically find possible query patterns + let query; + for (const pattern of this.queryPatterns) { + const match = message.match(pattern); + if (match) { + query = match[1]; + break; + } + } + if (!query) { + return undefined; + } + const args = { + query: query, + filesToInclude: '*.yml,*.yaml', // limit search to yml files + filesToExclude: '*.cbuild-idx.yml,*.cbuild.yml,*.cbuild-run.yml', // exclude generated or intermediate files + isRegex: false, + isCaseSensitive: false, + matchWholeWord: false, + triggerSearch: true, + focusResults: true, + }; + return encodeURIComponent(JSON.stringify(args)); + } +} + +export const SolutionProblems = constructor(SolutionProblemsImpl);