From 1cc3f84cf71c5e6c03647c96e47ddfec7f9ea63f Mon Sep 17 00:00:00 2001 From: Jason2866 <24528715+Jason2866@users.noreply.github.com> Date: Fri, 1 May 2026 22:03:05 +0200 Subject: [PATCH] Implement V120 (#11) --- IMPLEMENTATION_PLAN_v1.2.0.md | 639 +++++++++++++++++ __mocks__/vscode.ts | 171 +++++ .../backend/adapter-write-memory.test.ts | 244 +++++++ .../backend/breakpoint-error-handling.test.ts | 16 +- .../backend/mi2-leak-exploration.test.ts | 86 +++ __tests__/backend/mi2-preservation.test.ts | 173 +++++ __tests__/backend/rtos-detector.test.ts | 152 ++++ __tests__/backend/rtos-thread-mapping.test.ts | 110 +++ .../diagnostics/diagnostics-manager.test.ts | 354 ++++++++++ .../error-pattern-matching.test.ts | 216 ++++++ .../troubleshooting-wizard.test.ts | 283 ++++++++ .../frontend/memory-content-provider.test.ts | 287 ++++++++ __tests__/frontend/memory-data-types.test.ts | 245 +++++++ __tests__/frontend/memory-write.test.ts | 273 ++++++++ __tests__/frontend/peripheral-search.test.ts | 114 +++ .../frontend/register-change-tracking.test.ts | 95 +++ __tests__/frontend/svd-derivedfrom.test.ts | 228 ++++++ __tests__/frontend/svd-file-discovery.test.ts | 117 ++++ .../integration/feature-integration.test.ts | 497 +++++++++++++ jest.config.js | 4 + package-lock.json | 45 +- package.json | 2 +- src/backend/adapter.ts | 184 +++-- src/backend/mi2/mi2.ts | 1 + src/backend/rtos.ts | 658 ++++++++++++++++++ src/backend/symbols.ts | 12 +- src/extension.ts | 217 +++++- src/frontend/diagnostics.ts | 496 +++++++++++++ src/frontend/memory_content_provider.ts | 419 ++++++++++- src/frontend/peripheral.ts | 391 ++++++++++- src/utils.ts | 23 +- tsconfig.json | 2 +- 32 files changed, 6638 insertions(+), 116 deletions(-) create mode 100644 IMPLEMENTATION_PLAN_v1.2.0.md create mode 100644 __mocks__/vscode.ts create mode 100644 __tests__/backend/adapter-write-memory.test.ts create mode 100644 __tests__/backend/mi2-leak-exploration.test.ts create mode 100644 __tests__/backend/mi2-preservation.test.ts create mode 100644 __tests__/backend/rtos-detector.test.ts create mode 100644 __tests__/backend/rtos-thread-mapping.test.ts create mode 100644 __tests__/diagnostics/diagnostics-manager.test.ts create mode 100644 __tests__/diagnostics/error-pattern-matching.test.ts create mode 100644 __tests__/diagnostics/troubleshooting-wizard.test.ts create mode 100644 __tests__/frontend/memory-content-provider.test.ts create mode 100644 __tests__/frontend/memory-data-types.test.ts create mode 100644 __tests__/frontend/memory-write.test.ts create mode 100644 __tests__/frontend/peripheral-search.test.ts create mode 100644 __tests__/frontend/register-change-tracking.test.ts create mode 100644 __tests__/frontend/svd-derivedfrom.test.ts create mode 100644 __tests__/frontend/svd-file-discovery.test.ts create mode 100644 __tests__/integration/feature-integration.test.ts create mode 100644 src/backend/rtos.ts create mode 100644 src/frontend/diagnostics.ts diff --git a/IMPLEMENTATION_PLAN_v1.2.0.md b/IMPLEMENTATION_PLAN_v1.2.0.md new file mode 100644 index 0000000..3f03bd5 --- /dev/null +++ b/IMPLEMENTATION_PLAN_v1.2.0.md @@ -0,0 +1,639 @@ +# Implementation Plan for v1.2.0 + +**Target Release**: 1.2.0 +**Status**: Planning Phase +**Last Updated**: 2026-05-01 + +--- + +## Overview + +This document outlines the implementation plan for version 1.2.0 of the PlatformIO VSCode Debug extension. The four major features planned are: + +1. Enhanced peripheral viewer with SVD file support +2. Improved memory editor with data visualization +3. RTOS thread awareness +4. Better error messages and diagnostics + +--- + +## Feature 1: Enhanced Peripheral Viewer with SVD File Support + +### Current State +- Basic SVD parsing exists at `src/frontend/peripheral.ts` +- Uses `fast-xml-parser` for XML processing +- Supports peripherals, clusters, registers, and fields +- Current value display with hex/binary/decimal formatting + +### Planned Enhancements + +| Priority | Task | Effort | Files to Modify | +|----------|------|--------|-----------------| +| High | Add SVD file search/discovery from common paths | 2d | `peripheral.ts`, `extension.ts` | +| High | Implement peripheral search/filter UI | 2d | `peripheral.ts` | +| High | Add register change highlighting (diff from reset) | 3d | `peripheral.ts` | +| Medium | Support for SVD `` attribute | 2d | `peripheral.ts` | +| Medium | Add peripheral register bit-field tooltip documentation | 1d | `peripheral.ts` | +| Low | Export peripheral register map to JSON/Markdown | 2d | New file | + +### Technical Details + +#### SVD File Discovery +```typescript +// New method in PeripheralTreeProvider +private findSVDFile(deviceName: string): string | undefined { + const searchPaths = [ + `${workspaceRoot}/.vscode/*.svd`, + `${workspaceRoot}/*.svd`, + `${platformioPackages}/framework-*/svd/*.svd`, + `${platformioPackages}/tool-openocd/svd/*.svd`, + ]; + // Search logic here +} +``` + +#### Change Tracking +- Store `previousValue` alongside `currentValue` in `RegisterNode` +- Compare on each update to detect changes +- Apply VSCode decoration (e.g., colored background) to changed registers + +#### Search/Filter UI +- Use VSCode `QuickPick` API with `canPickMany: false` +- Filter peripherals by name or base address +- Keyboard shortcut: `Ctrl+Shift+P` → "Peripherals: Search" + +--- + +## Feature 2: Improved Memory Editor with Data Visualization + +### Current State +- Read-only hex dump at `src/frontend/memory_content_provider.ts` +- ASCII view on the right side +- Basic selection highlighting +- History tracking in `src/frontend/memory_tree_provider.ts` + +### Planned Enhancements + +| Priority | Task | Effort | Files to Modify | +|----------|------|--------|-----------------| +| High | Add editable memory cells (write support) | 3d | `memory_content_provider.ts`, `adapter.ts` | +| High | Add data type interpretation (u8/16/32/64, float, double) | 3d | `memory_content_provider.ts` | +| High | Add ASCII/string view toggle | 1d | `memory_content_provider.ts` | +| Medium | Add memory diff/highlighting capabilities | 2d | `memory_content_provider.ts` | +| Medium | Add endianness toggle (little/big) | 1d | `memory_content_provider.ts` | +| Low | Add memory bookmarking/named regions | 2d | `memory_tree_provider.ts` | + +### Technical Details + +#### Memory Write Implementation +```typescript +// New command in extension.ts +private async writeMemory(address: number, data: Uint8Array): Promise { + const session = vscode.debug.activeDebugSession; + if (!session) return; + + await session.customRequest('write-memory', { + address, + data: Buffer.from(data).toString('hex') + }); +} +``` + +#### Data Type Visualization +- Add toolbar dropdown to memory view +- Support: u8, u16, u32, u64, i8, i16, i32, i64, float, double +- Re-interpret bytes according to selected type and endianness +- Display in separate column alongside hex view + +#### Editing Workflow +1. User clicks on hex byte in editor +2. Input box appears for new value +3. Validate input (hex format) +4. Call `write-memory` debug request +5. Refresh view on success + +--- + +## Feature 3: RTOS Thread Awareness + +### Current State +- No RTOS support exists +- Basic thread handling in `adapter.ts` (GDB thread events) +- Uses `ThreadEvent` from `@vscode/debugadapter` + +### Planned Enhancements + +| Priority | Task | Effort | Files to Modify | +|----------|------|--------|-----------------| +| High | Create RTOS detection mechanism | 3d | New file `rtos.ts` | +| High | Implement FreeRTOS thread parser | 3d | `rtos.ts` | +| High | Add thread-aware stack frame mapping | 4d | `adapter.ts` | +| Medium | Implement ThreadX support | 2d | `rtos.ts` | +| Medium | Implement Zephyr support | 2d | `rtos.ts` | +| Medium | Add thread state display (blocked, ready, running) | 2d | `adapter.ts` | +| Low | Add thread priority display | 1d | `adapter.ts` | + +### Technical Details + +#### RTOS Detection +```typescript +// New file: src/backend/rtos.ts +export enum RTOSType { + None = 'none', + FreeRTOS = 'freertos', + ThreadX = 'threadx', + Zephyr = 'zephyr', + Unknown = 'unknown' +} + +export class RTOSDetector { + async detect(miDebugger: MI2): Promise { + // Check for FreeRTOS: look for pxCurrentTCB symbol + // Check for ThreadX: look for _tx_thread_current_ptr + // Check for Zephyr: look for _kernel.current + } +} +``` + +#### Thread Parsing (FreeRTOS Example) +```typescript +interface RTOSThread { + id: number; + name: string; + state: 'running' | 'ready' | 'blocked' | 'suspended'; + priority: number; + stackPointer: number; + stackInfo?: { base: number; size: number; used: number }; +} + +class FreeRTOSThreadParser { + async parseThreads(miDebugger: MI2): Promise { + // Read pxCurrentTCB to get current task + // Walk ready/blocked/suspended lists + // Parse TCB structures from memory + } +} +``` + +#### Configuration +```json +{ + "name": "PIO Debug", + "type": "platformio-debug", + "request": "launch", + "rtos": { + "type": "auto", + "enabled": true + } +} +``` + +#### Thread State Mapping +| FreeRTOS State | VSCode Thread State | +|----------------|---------------------| +| Running | `running` | +| Ready | `ready` | +| Blocked | `paused` | +| Suspended | `paused` | +| Deleted | `exited` | + +--- + +## Feature 4: Better Error Messages and Diagnostics + +### Current State +- Basic error messages in `src/extension.ts` +- Generic GDB/MI error handling +- No structured diagnostic system + +### Planned Enhancements + +| Priority | Task | Effort | Files to Modify | +|----------|------|--------|-----------------| +| High | Create centralized error message system | 2d | New file `diagnostics.ts` | +| High | Add connection troubleshooting wizard | 3d | `diagnostics.ts` | +| High | Improve GDB/MI error parsing | 2d | `mi2/mi2.ts` | +| Medium | Add diagnostic logging panel | 2d | `extension.ts` | +| Medium | Add SVD parse error recovery with suggestions | 1d | `peripheral.ts` | +| Low | Add "Report Issue" command with context collection | 2d | `extension.ts` | + +### Technical Details + +#### Centralized Error System +```typescript +// New file: src/diagnostics.ts +export interface ErrorAction { + label: string; + callback: () => void; +} + +export class DiagnosticsManager { + showError(message: string, actions?: ErrorAction[]): void { + if (actions && actions.length > 0) { + vscode.window.showErrorMessage(message, ...actions.map(a => a.label)) + .then(selected => { + const action = actions.find(a => a.label === selected); + action?.callback(); + }); + } else { + vscode.window.showErrorMessage(message); + } + } + + // Predefined error patterns with solutions + handleGDBConnectionError(error: string): void { + // Suggest checking OpenOCD/GDB server status + // Offer to restart debug session + } +} +``` + +#### Common Error Patterns +| Error Pattern | Suggested Action | +|---------------|------------------| +| "Connection refused" | Check OpenOCD status, port availability | +| "No such file or directory" | Verify SVD file path in launch.json | +| "Cannot access memory" | Target may not be halted; try pausing | +| "Remote replied with error" | GDB server protocol mismatch | + +#### Diagnostic Logging +- Add output channel: "PlatformIO Debug Diagnostics" +- Log all GDB/MI commands and responses when `showDevDebugOutput: true` +- Log peripheral/memory operations +- Export diagnostic log for bug reports + +--- + +## pioarduino-vscode-ide Integration Changes + +The `pioarduino-vscode-ide` extension ([`debug_120` branch](https://github.com/Jason2866/pioarduino-vscode-ide/tree/debug_120)) defines the UI layer (commands, views, menus) while this debug extension provides the implementation. The following changes are needed in the IDE extension: + +### package.json Changes Required + +#### New Commands to Add +```json +{ + "command": "platformio-debug.peripherals.search", + "title": "Search Peripherals", + "category": "PlatformIO Debug", + "icon": "$(search)" +}, +{ + "command": "platformio-debug.memory.edit", + "title": "Edit Memory", + "category": "PlatformIO Debug", + "icon": "$(edit)" +}, +{ + "command": "platformio-debug.memory.setDataType", + "title": "Set Data Type", + "category": "PlatformIO Debug" +}, +{ + "command": "platformio-debug.memory.toggleEndianness", + "title": "Toggle Endianness", + "category": "PlatformIO Debug" +}, +{ + "command": "platformio-debug.rtos.refreshThreads", + "title": "Refresh RTOS Threads", + "category": "PlatformIO Debug", + "icon": "$(refresh)" +}, +{ + "command": "platformio-debug.diagnostics.showLog", + "title": "Show Debug Diagnostics", + "category": "PlatformIO Debug" +} +``` + +#### New Configuration Properties +```json +"platformio-debug.rtos.enabled": { + "type": "boolean", + "default": true, + "description": "Enable RTOS thread awareness (FreeRTOS, ThreadX, Zephyr)" +}, +"platformio-debug.rtos.type": { + "type": "string", + "enum": ["auto", "FreeRTOS", "ThreadX", "Zephyr", "none"], + "default": "auto", + "description": "RTOS type for thread awareness (auto-detect if not specified)" +}, +"platformio-debug.memory.defaultDataType": { + "type": "string", + "enum": ["u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64", "float", "double"], + "default": "u8", + "description": "Default data type for memory view" +}, +"platformio-debug.memory.defaultEndianness": { + "type": "string", + "enum": ["little", "big"], + "default": "little", + "description": "Default endianness for memory view" +}, +"platformio-debug.diagnostics.showDevDebugOutput": { + "type": "boolean", + "default": false, + "description": "Show detailed diagnostic logs for troubleshooting" +} +``` + +#### New Views (Optional) +- **RTOS Threads Panel**: Add to debug view container alongside peripherals/registers +- **Diagnostics Panel**: Output channel for diagnostic messages + +### File Locations in IDE Extension +```text +https://github.com/Jason2866/pioarduino-vscode-ide/tree/debug_120 +├── package.json (modified: commands, config, views, rtos launch property) +├── syntaxes/ (existing: language definitions) +└── src/ (if UI logic needed, typically just package.json) +``` + +--- + +## Proposed File Structure (Debug Extension) + +```text +src/ +├── backend/ +│ ├── adapter.ts (modify: RTOS thread awareness) +│ ├── mi2/ +│ │ ├── mi2.ts (modify: error parsing) +│ │ └── types.ts (may need RTOS types) +│ ├── rtos.ts (NEW: RTOS parsers) +│ └── symbols.ts (existing) +├── frontend/ +│ ├── peripheral.ts (modify: SVD enhancements) +│ ├── memory_content_provider.ts (modify: editing/visualization) +│ ├── memory_tree_provider.ts (modify: bookmarks) +│ └── diagnostics.ts (NEW: error handling) +├── common.ts (modify: add types) +└── extension.ts (modify: register commands) +``` + +--- + +## Development Phases + +### Phase 1: Foundation +- [x] Create `diagnostics.ts` with error handling framework +- [x] Implement diagnostic logging panel +- [x] Add centralized error message system +- [x] `__tests__/diagnostics/troubleshooting-wizard.test.ts` created +- [x] **IDE Extension**: Add diagnostic configuration to `package.json` + +### Phase 2: Memory Editor +- [x] Add `write-memory` support in `adapter.ts` +- [x] Implement editable hex view in `memory_content_provider.ts` +- [x] Add ASCII/string view toggle in `memory_content_provider.ts` +- [x] Add memory diff/highlighting in `memory_content_provider.ts` +- [x] Add data type interpretation panel +- [x] Add endianness toggle +- [x] `__tests__/backend/adapter-write-memory.test.ts` created +- [x] **IDE Extension**: Add memory edit commands and configuration to `package.json` + +### Phase 3: SVD Enhancements +- [x] Implement SVD file discovery +- [x] Add peripheral search/filter UI +- [x] Implement change highlighting +- [x] Add `` support +- [x] Add peripheral register bit-field tooltip documentation +- [x] **IDE Extension**: Add peripheral search command to `package.json` + +### Phase 4: RTOS Support +- [x] Create RTOS detection mechanism +- [x] Implement FreeRTOS parser +- [x] Add thread-aware stack mapping +- [x] Add ThreadX and Zephyr support +- [x] **IDE Extension**: Add RTOS configuration to `package.json` + +### Phase 5: Polish & Testing +- [x] Integration testing for all features (`__tests__/integration/feature-integration.test.ts`) +- [x] **IDE Extension**: Test all new commands and configurations +- [ ] Documentation updates +- [ ] Bug fixes and edge cases + +--- + +## Testing Requirements + +| Feature | Unit Tests | Integration Tests | Manual Tests | +|---------|------------|-------------------|--------------| +| SVD Enhancements | SVD parsing edge cases | Search/filter UI | Real device SVD load | +| Memory Editor | Data type conversions | Write operations | Various memory regions | +| RTOS Awareness | Mock RTOS structures | Thread switching | FreeRTOS/ThreadX/Zephyr targets | +| Diagnostics | Error pattern matching | Troubleshooting wizard | Various error scenarios | + +--- + +## Test Generation Plan + +### New Test Files to Create + +#### 1. SVD Enhancements Tests +``` +__tests__/ +├── frontend/ +│ ├── svd-file-discovery.test.ts (NEW: SVD path resolution) +│ ├── peripheral-search.test.ts (NEW: search/filter logic) +│ ├── register-change-tracking.test.ts (NEW: value diff detection) +│ └── svd-derivedfrom.test.ts (NEW: inheritance parsing) +``` + +**Test Coverage Targets:** +- SVD file discovery: Test all search path patterns +- Peripheral search: Test fuzzy matching, case sensitivity +- Change tracking: Verify highlighting triggers on value change +- ``: Test circular references, nested inheritance + +#### 2. Memory Editor Tests +``` +__tests__/ +├── frontend/ +│ ├── memory-write.test.ts (NEW: write-memory request) +│ ├── memory-data-types.test.ts (NEW: type interpretation) +│ ├── memory-endianness.test.ts (NEW: byte order handling) +│ └── memory-bookmarks.test.ts (NEW: named regions) +├── backend/ +│ └── adapter-write-memory.test.ts (NEW: write-memory handler) +``` + +**Test Coverage Targets:** +- Write operations: Test byte alignment, partial writes +- Data types: Test u8/u16/u32/u64/i8/i16/i32/i64/float/double +- Endianness: Verify little/big endian conversion +- Input validation: Test invalid hex, out-of-bounds addresses + +#### 3. RTOS Awareness Tests +``` +__tests__/ +├── backend/ +│ ├── rtos-detector.test.ts (NEW: auto-detection logic) +│ ├── freertos-parser.test.ts (NEW: FreeRTOS TCB parsing) +│ ├── threadx-parser.test.ts (NEW: ThreadX thread parsing) +│ ├── zephyr-parser.test.ts (NEW: Zephyr kernel parsing) +│ └── rtos-thread-mapping.test.ts (NEW: thread-to-frame mapping) +``` + +**Test Coverage Targets:** +- Detection: Mock GDB symbol table responses +- FreeRTOS: Test TCB structure parsing, state mapping +- ThreadX: Test thread list walking +- Zephyr: Test kernel thread table access +- Thread mapping: Verify frame ID assignment + +#### 4. Diagnostics Tests +``` +__tests__/ +├── diagnostics/ +│ ├── error-pattern-matching.test.ts (NEW: error classification) +│ ├── diagnostics-manager.test.ts (NEW: error actions) +│ └── troubleshooting-wizard.test.ts (NEW: diagnostic flow) +``` + +**Test Coverage Targets:** +- Error patterns: Test regex matching for common GDB errors +- Actions: Verify callback execution for error actions +- Logging: Test output channel formatting + +### Modified Existing Tests + +Files requiring updates for new functionality: + +| File | Changes Needed | +|------|----------------| +| `__tests__/frontend/device-defaults.test.ts` | Add tests for SVD inheritance | +| `__tests__/mi2/breakpoint-parsing.test.ts` | Verify still passes (regression check) | +| `__tests__/backend/breakpoint-error-handling.test.ts` | Add error classification tests | + +### Test Infrastructure + +#### Mock Data Files +``` +__tests__/ +├── mocks/ +│ ├── rtos/ +│ │ ├── freertos-tcb.bin (Mock TCB structures) +│ │ ├── threadx-thread.bin (Mock ThreadX thread) +│ │ └── zephyr-kernel.bin (Mock Zephyr kernel) +│ ├── svd/ +│ │ ├── test-device.svd (Test SVD with derivedFrom) +│ │ └── search-test/ (Directory for discovery tests) +│ └── memory/ +│ └── test-regions.json (Memory test configurations) +``` + +#### Test Utilities (New) +```typescript +// __tests__/utils/rtos-mocks.ts +export function createMockTCB(state: string, priority: number): Buffer; +export function createMockThreadList(count: number): Buffer; + +// __tests__/utils/memory-mocks.ts +export function createMockMemoryBuffer(size: number, pattern: string): number[]; +export function validateMemoryWrite(address: number, data: string): boolean; +``` + +### Regression Test Suite + +All existing tests must pass: +```bash +npm test +# Expected: 133 tests passing (current) + new tests +``` + +New features should not break: +- MI2/MI3/MI4 protocol compatibility +- Existing peripheral viewer functionality +- Current memory read operations +- Breakpoint handling + +### Test-Driven Development Order + +1. **Write failing tests first** for each feature +2. **Implement feature** to make tests pass +3. **Refactor** while maintaining test coverage +4. **Add edge case tests** after initial implementation + +### Coverage Requirements by Feature + +| Feature | Minimum Coverage | Critical Paths | +|---------|------------------|----------------| +| SVD Enhancements | 85% | File discovery, change detection | +| Memory Editor | 90% | Write operations, type conversion | +| RTOS Awareness | 80% | Detection, thread parsing | +| Diagnostics | 85% | Error classification, actions | + +--- + +## Dependencies + +- No new runtime dependencies expected +- May require `@types/node` updates for buffer operations +- Development dependencies: jest for testing (already present) + +--- + +## Risk Assessment + +| Risk | Impact | Mitigation | +|------|--------|------------| +| RTOS parsing fragile across versions | High | Implement version detection, graceful fallback | +| Memory write safety | High | Add confirmation dialogs, validate addresses | +| SVD file discovery performance | Low | Cache search results, async loading | +| GDB/MI version differences | Medium | Test with MI2/MI3/MI4 (already supported) | + +--- + +## Success Criteria + +### Debug Extension (pioarduino-vscode-debug) +- [ ] All 4 feature areas have measurable improvements +- [ ] Test coverage > 80% for new code +- [ ] No regressions in existing functionality (353 tests passing) +- [ ] Documentation updated in README.md +- [ ] CHANGELOG.md updated with detailed entries + +### IDE Extension (pioarduino-vscode-ide) +- [ ] Diagnostic configuration added and verifiable in `package.json` +- [ ] All new commands registered in `package.json` +- [ ] New configuration properties added and functional +- [ ] No conflicts with existing debug commands +- [ ] Updated extension README with new features + +--- + +## Notes + +- Keep backward compatibility with MI2 protocol +- Maintain support for existing launch.json configurations +- Follow existing code style (no semicolons, single quotes, 4-space indent) +- Reuse existing utility functions from `src/utils.ts` + +## Release Coordination + +The debug extension and IDE extension releases should be coordinated: + +1. **Debug Extension Release**: Must be published first (contains implementation) +2. **IDE Extension Release**: Published after with updated `package.json` (contains UI definitions) + +Both extensions can be developed in parallel, but the IDE extension's package.json changes should reference commands/features that exist in the debug extension version it depends on. + +### Version Compatibility + +| IDE Extension | Debug Extension | Notes | +|---------------|-----------------|-------| +| 1.3.x | 1.1.x | Current stable | +| 1.4.x | 1.2.0 | With v1.2.0 features | + +### Files to Modify in IDE Extension + +Location: [`package.json`](https://github.com/Jason2866/pioarduino-vscode-ide/blob/debug_120/package.json) + +Key sections to update: +- `contributes.commands` - Add new command definitions +- `contributes.menus` - Add menu items for new commands +- `contributes.configuration` - Add new configuration properties +- `contributes.views` - Add new RTOS/diagnostics views if applicable diff --git a/__mocks__/vscode.ts b/__mocks__/vscode.ts new file mode 100644 index 0000000..fa39c1d --- /dev/null +++ b/__mocks__/vscode.ts @@ -0,0 +1,171 @@ +/** + * Mock for vscode module used in tests + */ + +const mockOutputChannel = { + appendLine: jest.fn(), + append: jest.fn(), + clear: jest.fn(), + show: jest.fn(), + hide: jest.fn(), + dispose: jest.fn(), + replace: jest.fn() +} + +const mockClipboard = { + writeText: jest.fn().mockResolvedValue(undefined), + readText: jest.fn().mockResolvedValue('') +} + +const mockUri = { + parse: jest.fn((uri: string) => ({ fsPath: uri, toString: () => uri })), + file: jest.fn((path: string) => ({ fsPath: path, toString: () => `file://${path}` })) +} + +// Helper to create a proper Thenable mock backed by a real Promise so that +// callers get standard async scheduling, chaining and error semantics. +const createMockThenable = (resolvedValue?: any): Promise => { + return Promise.resolve(resolvedValue) +} + +export const window = { + createOutputChannel: jest.fn().mockReturnValue(mockOutputChannel), + showErrorMessage: jest.fn().mockImplementation(() => createMockThenable(undefined)), + showWarningMessage: jest.fn().mockImplementation(() => createMockThenable(undefined)), + showInformationMessage: jest.fn().mockImplementation(() => createMockThenable(undefined)), + showOpenDialog: jest.fn().mockImplementation(() => Promise.resolve(undefined)), + showQuickPick: jest.fn().mockImplementation(() => Promise.resolve(undefined)), + showInputBox: jest.fn().mockImplementation(() => Promise.resolve(undefined)), + activeTextEditor: undefined, + visibleTextEditors: [], + onDidChangeActiveTextEditor: jest.fn().mockReturnValue({ dispose: jest.fn() }) +} + +export const env = { + clipboard: mockClipboard, + openExternal: jest.fn().mockResolvedValue(true), + appName: 'vscode-test', + appRoot: '/test', + appHost: 'desktop', + uiKind: 1 +} + +export const Uri = mockUri + +export const commands = { + executeCommand: jest.fn().mockResolvedValue(undefined), + registerCommand: jest.fn().mockReturnValue({ dispose: jest.fn() }), + getCommands: jest.fn().mockResolvedValue([]) +} + +export const debug = { + activeDebugSession: undefined, + onDidChangeActiveDebugSession: jest.fn().mockReturnValue({ dispose: jest.fn() }), + onDidStartDebugSession: jest.fn().mockReturnValue({ dispose: jest.fn() }), + onDidTerminateDebugSession: jest.fn().mockReturnValue({ dispose: jest.fn() }) +} + +export const workspace = { + workspaceFolders: undefined, + onDidChangeWorkspaceFolders: jest.fn().mockReturnValue({ dispose: jest.fn() }), + getConfiguration: jest.fn().mockReturnValue({ + get: jest.fn(), + has: jest.fn(), + update: jest.fn().mockResolvedValue(undefined) + }), + registerTextDocumentContentProvider: jest.fn().mockReturnValue({ dispose: jest.fn() }) +} + +export const EventEmitter = jest.fn().mockImplementation(() => ({ + event: jest.fn(), + fire: jest.fn(), + dispose: jest.fn() +})) + +export const TreeItemCollapsibleState = { + None: 0, + Collapsed: 1, + Expanded: 2 +} + +export const OverviewRulerLane = { + Left: 1, + Center: 2, + Right: 4, + Full: 7 +} + +export const Position = jest.fn().mockImplementation((line: number, character: number) => ({ + line, + character, + compareTo: jest.fn(), + isAfter: jest.fn(), + isAfterOrEqual: jest.fn(), + isBefore: jest.fn(), + isBeforeOrEqual: jest.fn(), + isEqual: jest.fn(), + translate: jest.fn(), + with: jest.fn() +})) + +export const Range = jest.fn().mockImplementation((startOrLine: any, startOrChar: any, endLine?: number, endChar?: number) => { + const isPositionLike = (value: any) => + value && typeof value.line === 'number' && typeof value.character === 'number' + + const start = isPositionLike(startOrLine) && isPositionLike(startOrChar) + ? { line: startOrLine.line, character: startOrLine.character } + : { line: startOrLine, character: startOrChar } + const end = isPositionLike(startOrLine) && isPositionLike(startOrChar) + ? { line: startOrChar.line, character: startOrChar.character } + : { line: endLine, character: endChar } + + return { + start, + end, + isEmpty: jest.fn(), + isSingleLine: jest.fn(), + contains: jest.fn(), + intersection: jest.fn(), + union: jest.fn(), + with: jest.fn() + } +}) + +export const ThemeIcon = jest.fn().mockImplementation((id: string, color?: any) => ({ + id, + color +})) + +export const ThemeColor = jest.fn().mockImplementation((id: string) => ({ id })) + +export class TreeItem { + public label: any + public collapsibleState: any + public command: any + public tooltip: any + public iconPath: any + public contextValue: any + public description: any + constructor(label: any, collapsibleState?: any) { + this.label = label + this.collapsibleState = collapsibleState + } +} + +// Default export +export default { + window, + env, + Uri, + commands, + debug, + workspace, + EventEmitter, + TreeItemCollapsibleState, + OverviewRulerLane, + Position, + Range, + ThemeIcon, + ThemeColor, + TreeItem +} diff --git a/__tests__/backend/adapter-write-memory.test.ts b/__tests__/backend/adapter-write-memory.test.ts new file mode 100644 index 0000000..6676ef4 --- /dev/null +++ b/__tests__/backend/adapter-write-memory.test.ts @@ -0,0 +1,244 @@ +/** + * Unit tests for the write-memory dispatch path in adapter.ts. + * + * Tests that: + * 1. The 'write-memory' custom-request case calls customWriteMemoryRequest + * with the correct address and data arguments. + * 2. customWriteMemoryRequest constructs the right GDB/MI command: + * `data-write-memory-bytes ` + * 3. On MI success, sendResponse is called once. + * 4. On MI failure, sendErrorResponse is called with code 114. + */ + +// --------------------------------------------------------------------------- +// Minimal stubs for heavyweight dependencies +// --------------------------------------------------------------------------- + +jest.mock('@vscode/debugadapter', () => { + class FakeHandles { + private map = new Map(); + private nextId = 1; + create(v: any): number { + const id = this.nextId++; + this.map.set(id, v); + return id; + } + get(id: number): any { + return this.map.get(id); + } + reset(): void { + this.map.clear(); + this.nextId = 1; + } + } + + class FakeDebugSession { + sendResponse(_response: any) {} + sendErrorResponse(_response: any, _code: number, _message: string) {} + sendEvent(_event: any) {} + } + + class FakeEvent { + constructor(public event: string, public body?: any) {} + } + + return { + DebugSession: FakeDebugSession, + Event: FakeEvent, + Handles: FakeHandles, + InitializedEvent: class extends FakeEvent { constructor() { super('initialized'); } }, + OutputEvent: class extends FakeEvent {}, + TerminatedEvent: class extends FakeEvent {}, + ThreadEvent: class extends FakeEvent {}, + Thread: class {}, + StackFrame: class {}, + Scope: class {}, + Source: class {}, + ContinuedEvent: class extends FakeEvent {}, + }; +}); + +jest.mock('../../src/backend/mi2/mi2', () => ({ + MI2: class { + sendCommand = jest.fn(); + on = jest.fn(); + emit = jest.fn(); + }, +})); + +jest.mock('../../src/backend/symbols', () => ({ + SymbolTable: class { + getFunctionSymbols() { return []; } + getFunctionAtAddress() { return null; } + getSourceLines() { return []; } + }, +})); + +jest.mock('../../src/backend/rtos', () => ({ + RTOSManager: class { + detectRTOS() { return Promise.resolve(undefined); } + getThreads() { return []; } + }, + RTOSType: { None: 'none' }, +})); + +jest.mock('../../src/common', () => ({ + StoppedEvent: class { constructor(public event: string, public body?: any) {} }, + AdapterOutputEvent: class { constructor(public event: string, public body?: any) {} }, +})); + +jest.mock('../../src/backend/expand_value', () => ({ + expandValue: jest.fn().mockReturnValue({}), +})); + +jest.mock('../../src/backend/mi_parse', () => ({ + MINode: class {}, +})); + +// --------------------------------------------------------------------------- +// Import under test +// --------------------------------------------------------------------------- +import { GDBDebugSession } from '../../src/backend/adapter'; +import { MI2 } from '../../src/backend/mi2/mi2'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createSession(): any { + const session = new GDBDebugSession() as any; + // Inject a mock MI2 instance so customWriteMemoryRequest has a debugger + session.miDebugger = new (MI2 as any)(); + // Spy on sendResponse / sendErrorResponse + session.sendResponse = jest.fn(); + session.sendErrorResponse = jest.fn(); + return session; +} + +function makeResponse(): any { + return { seq: 1, type: 'response', request_seq: 1, success: false, command: 'custom' }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('adapter.ts — write-memory dispatch (customWriteMemoryRequest)', () => { + let session: any; + + beforeEach(() => { + session = createSession(); + jest.clearAllMocks(); + session.miDebugger.sendCommand = jest.fn(); + }); + + describe('MI command construction', () => { + test('sends data-write-memory-bytes with zero-padded hex address', async () => { + session.miDebugger.sendCommand.mockReturnValue({ then: (cb: any) => { cb({}); return { catch: jest.fn() }; } }); + + session.customWriteMemoryRequest(makeResponse(), 0x20000000, 'aabbccdd'); + + expect(session.miDebugger.sendCommand).toHaveBeenCalledWith( + 'data-write-memory-bytes 0x20000000 aabbccdd' + ); + }); + + test('formats address with full 8-digit hex padding', () => { + session.miDebugger.sendCommand.mockReturnValue({ + then: (cb: any) => { cb({}); return { catch: jest.fn() }; } + }); + + session.customWriteMemoryRequest(makeResponse(), 0x100, 'ff'); + + expect(session.miDebugger.sendCommand).toHaveBeenCalledWith( + 'data-write-memory-bytes 0x00000100 ff' + ); + }); + + test('passes data string unmodified to the MI command', () => { + session.miDebugger.sendCommand.mockReturnValue({ + then: (cb: any) => { cb({}); return { catch: jest.fn() }; } + }); + + session.customWriteMemoryRequest(makeResponse(), 0x20000004, '01020304050607'); + + expect(session.miDebugger.sendCommand).toHaveBeenCalledWith( + 'data-write-memory-bytes 0x20000004 01020304050607' + ); + }); + }); + + describe('success path', () => { + test('calls sendResponse on MI success', () => { + const response = makeResponse(); + session.miDebugger.sendCommand.mockReturnValue({ + then: (onFulfilled: any) => { + onFulfilled({}); + return { catch: jest.fn() }; + }, + }); + + session.customWriteMemoryRequest(response, 0x20000000, 'ab'); + + expect(session.sendResponse).toHaveBeenCalledWith(response); + expect(session.sendErrorResponse).not.toHaveBeenCalled(); + }); + }); + + describe('failure path', () => { + test('calls sendErrorResponse with code 114 on MI failure', () => { + const response = makeResponse(); + const error = new Error('target memory error'); + session.miDebugger.sendCommand.mockReturnValue({ + then: (_onFulfilled: any, onRejected: any) => { + onRejected(error); + return { catch: jest.fn() }; + }, + }); + + session.customWriteMemoryRequest(response, 0x20000000, 'ab'); + + expect(session.sendErrorResponse).toHaveBeenCalledWith( + response, + 114, + expect.stringContaining('Unable to write memory') + ); + expect(session.sendResponse).not.toHaveBeenCalled(); + }); + + test('error response message includes the MI error description', () => { + const response = makeResponse(); + const error = new Error('cannot access memory at 0x20000000'); + session.miDebugger.sendCommand.mockReturnValue({ + then: (_onFulfilled: any, onRejected: any) => { + onRejected(error); + return { catch: jest.fn() }; + }, + }); + + session.customWriteMemoryRequest(response, 0x20000000, 'ff'); + + const errorCall = (session.sendErrorResponse as jest.Mock).mock.calls[0]; + expect(errorCall[2]).toMatch(/cannot access memory/i); + }); + }); + + describe('write-memory custom request dispatch', () => { + test('customRequest routes write-memory to customWriteMemoryRequest', () => { + // Spy on the private method via reflection + const spy = jest.spyOn(session as any, 'customWriteMemoryRequest'); + session.miDebugger.sendCommand.mockReturnValue({ + then: (cb: any) => { cb({}); return { catch: jest.fn() }; } + }); + + const response = makeResponse(); + // customRequest signature: (command, response, args) + session.customRequest('write-memory', response, { + address: 0x20000000, + data: 'deadbeef', + }); + + expect(spy).toHaveBeenCalledWith(response, 0x20000000, 'deadbeef'); + }); + }); +}); diff --git a/__tests__/backend/breakpoint-error-handling.test.ts b/__tests__/backend/breakpoint-error-handling.test.ts index 2e18c20..b763908 100644 --- a/__tests__/backend/breakpoint-error-handling.test.ts +++ b/__tests__/backend/breakpoint-error-handling.test.ts @@ -3,9 +3,17 @@ import { parseMI } from '../../src/backend/mi_parse'; describe('Breakpoint Error Handling', () => { describe('addBreakPoint null return handling', () => { + let mi2: MI2; + + beforeEach(() => { + mi2 = new MI2('gdb', []); + }); + + afterEach(() => { + mi2.removeAllListeners(); + }); + test('should return null when breakpoint number parsing fails', async () => { - const mi2 = new MI2('gdb', []); - // Mock sendCommand to return invalid response mi2.sendCommand = jest.fn().mockResolvedValue({ resultRecords: { resultClass: 'done' }, @@ -25,8 +33,6 @@ describe('Breakpoint Error Handling', () => { }); test('should return null when GDB returns error', async () => { - const mi2 = new MI2('gdb', []); - // Mock sendCommand to return error response mi2.sendCommand = jest.fn().mockResolvedValue({ resultRecords: { resultClass: 'error' }, @@ -42,8 +48,6 @@ describe('Breakpoint Error Handling', () => { }); test('should return breakpoint object when successful', async () => { - const mi2 = new MI2('gdb', []); - // Mock sendCommand to return valid response mi2.sendCommand = jest.fn().mockResolvedValue({ resultRecords: { resultClass: 'done' }, diff --git a/__tests__/backend/mi2-leak-exploration.test.ts b/__tests__/backend/mi2-leak-exploration.test.ts new file mode 100644 index 0000000..d47cf62 --- /dev/null +++ b/__tests__/backend/mi2-leak-exploration.test.ts @@ -0,0 +1,86 @@ +/** + * Bug Condition Exploration Tests — Task 1 + * + * These tests surface the two leaks that cause Jest to force-exit its worker: + * + * Cause A — Unref'd timer: MI2.onOutput() creates a setTimeout without .unref(), + * keeping the Node.js event loop alive after tests finish. + * + * Cause B — Dangling listeners: MI2 instances created in tests without afterEach + * cleanup leave EventEmitter listeners registered on live objects. + * + * EXPECTED OUTCOME ON UNFIXED CODE: + * - Cause A test FAILS → hasRef() returns true (timer keeps event loop alive) + * - Cause B test PASSES → MI2 constructor adds no listeners itself + * (the leak pattern is about not having afterEach, not about constructor listeners) + * + * DO NOT modify mi2.ts or fix these tests when they fail. + * Failure of Cause A confirms the bug exists. + * + * Validates: Requirements 1.1, 1.2, 1.3 + */ + +import { MI2 } from '../../src/backend/mi2/mi2'; + +// --------------------------------------------------------------------------- +// Cause A — Timer leak +// --------------------------------------------------------------------------- +describe('Cause A — Timer leak: debugReadyTimeout should be unref\'d', () => { + let mi2: MI2; + + afterEach(() => { + // Clean up the timer so it doesn't fire during other tests + if (mi2['debugReadyTimeout']) { + clearTimeout(mi2['debugReadyTimeout']); + } + mi2.removeAllListeners(); + }); + + test( + 'hasRef() should be false after onOutput() triggers the PlatformIO init branch', + () => { + mi2 = new MI2('gdb', []); + + // A valid GDB console stream record: ~"\n" + // The ~ prefix makes parseMI produce isStream: true with type 'console' + const triggerLine = '~"PlatformIO: Initialization completed\\n"'; + + mi2.onOutput(triggerLine); + + // The timer must have been created + expect(mi2['debugReadyTimeout']).toBeDefined(); + + // On UNFIXED code this assertion FAILS because .unref() is never called. + // hasRef() returns true → the timer keeps the event loop alive. + // On FIXED code this assertion PASSES because .unref() is called. + expect(mi2['debugReadyTimeout'].hasRef()).toBe(false); + } + ); +}); + +// --------------------------------------------------------------------------- +// Cause B — Listener leak +// --------------------------------------------------------------------------- +describe('Cause B — Listener leak: MI2 instance should have zero listeners after construction', () => { + /** + * NOTE: The MI2 constructor does NOT add any listeners itself. + * The leak pattern described in the bug report is about tests that create MI2 + * instances without an afterEach cleanup block — those instances accumulate + * listeners added during the test body and are never cleaned up. + * + * This test verifies the baseline: a freshly constructed MI2 instance has + * zero 'msg' listeners. If this passes, it documents that Cause B is about + * the *pattern* of missing afterEach, not about listeners added in the constructor. + */ + test( + 'a freshly constructed MI2 instance has zero msg listeners', + () => { + const mi2 = new MI2('gdb', []); + + // No afterEach cleanup is intentionally omitted here to mirror the + // pattern in breakpoint-error-handling.test.ts. + // The constructor itself adds no listeners, so this should be 0. + expect(mi2.listenerCount('msg')).toBe(0); + } + ); +}); diff --git a/__tests__/backend/mi2-preservation.test.ts b/__tests__/backend/mi2-preservation.test.ts new file mode 100644 index 0000000..e09318f --- /dev/null +++ b/__tests__/backend/mi2-preservation.test.ts @@ -0,0 +1,173 @@ +/** + * Preservation Property Tests — Task 2 + * + * These tests establish the baseline behavior that MUST be preserved after the fix. + * They MUST PASS on unfixed code. + * + * Property 2: Preservation — `debug-ready` Emission and Breakpoint Test Assertions Unchanged + * + * Validates: Requirements 3.1, 3.2, 3.3, 3.4 + */ + +import { MI2 } from '../../src/backend/mi2/mi2'; + +// --------------------------------------------------------------------------- +// Test A — debug-ready emitted after 200ms +// --------------------------------------------------------------------------- +describe('Test A — debug-ready emitted after 200ms', () => { + let mi2: MI2; + + beforeEach(() => { + jest.useFakeTimers(); + mi2 = new MI2('gdb', []); + }); + + afterEach(() => { + // Clean up the timer if it's still pending + if (mi2['debugReadyTimeout']) { + clearTimeout(mi2['debugReadyTimeout']); + } + mi2.removeAllListeners(); + jest.useRealTimers(); + }); + + /** + * Validates: Requirement 3.1 + * When onOutput() detects 'PlatformIO: Initialization completed' and the timeout + * fires normally, 'debug-ready' MUST be emitted after 200ms. + */ + test('emits debug-ready exactly once after 200ms when trigger line is processed', () => { + let debugReadyCount = 0; + mi2.on('debug-ready', () => { + debugReadyCount++; + }); + + // A valid GDB console stream record containing the trigger string + mi2.onOutput('~"PlatformIO: Initialization completed\\n"'); + + // Timer should be set but not yet fired + expect(debugReadyCount).toBe(0); + + // Advance fake timers by 200ms — the timeout should fire + jest.advanceTimersByTime(200); + + expect(debugReadyCount).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Test B — debug-ready emitted immediately on generic-stopped +// --------------------------------------------------------------------------- +describe('Test B — debug-ready emitted immediately on generic-stopped', () => { + let mi2: MI2; + + beforeEach(() => { + jest.useFakeTimers(); + mi2 = new MI2('gdb', []); + }); + + afterEach(() => { + mi2.removeAllListeners(); + jest.useRealTimers(); + }); + + /** + * Validates: Requirement 3.2 + * When 'generic-stopped' fires before the 200ms timeout, 'debug-ready' MUST be + * emitted immediately and the timer MUST be cancelled (no second emission). + */ + test('emits debug-ready immediately when generic-stopped fires before 200ms', () => { + let debugReadyCount = 0; + mi2.on('debug-ready', () => { + debugReadyCount++; + }); + + // Set up the timer by processing the trigger line + mi2.onOutput('~"PlatformIO: Initialization completed\\n"'); + + // Timer is pending but has not fired yet + expect(debugReadyCount).toBe(0); + + // Emit generic-stopped BEFORE advancing timers — should trigger immediate emission + mi2.emit('generic-stopped', {}); + + // debug-ready should have been emitted immediately + expect(debugReadyCount).toBe(1); + + // Advance timers by 200ms — the original timeout should have been cancelled, + // so no second emission should occur + jest.advanceTimersByTime(200); + + expect(debugReadyCount).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// Test C — non-trigger lines produce no timer +// --------------------------------------------------------------------------- +describe('Test C — non-trigger lines produce no timer', () => { + let mi2: MI2; + + beforeEach(() => { + mi2 = new MI2('gdb', []); + }); + + afterEach(() => { + mi2.removeAllListeners(); + }); + + /** + * Validates: Requirement 3.1 (by contrapositive) + * Lines that do NOT contain 'PlatformIO: Initialization completed' MUST NOT + * create a debugReadyTimeout. + */ + test('empty string does not create a debugReadyTimeout', () => { + mi2.onOutput(''); + expect(mi2['debugReadyTimeout']).toBeUndefined(); + }); + + test('plain stdout line does not create a debugReadyTimeout', () => { + mi2.onOutput('~"Some other output\\n"'); + expect(mi2['debugReadyTimeout']).toBeUndefined(); + }); + + test('a different stream record does not create a debugReadyTimeout', () => { + // @ prefix = target stream record (not console) + mi2.onOutput('@"target output\\n"'); + expect(mi2['debugReadyTimeout']).toBeUndefined(); + }); + + test('a GDB result record does not create a debugReadyTimeout', () => { + // A result record (^done) — not a stream record + mi2.onOutput('^done'); + expect(mi2['debugReadyTimeout']).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Test D — removeAllListeners() zeroes listener count +// --------------------------------------------------------------------------- +describe('Test D — removeAllListeners() zeroes listener count', () => { + /** + * Validates: Requirement 3.4 (cleanup pattern) + * After calling removeAllListeners(), all listener counts MUST be 0. + */ + test('listener counts are 0 after removeAllListeners()', () => { + const mi2 = new MI2('gdb', []); + + // Add some listeners manually + mi2.on('msg', () => {}); + mi2.on('debug-ready', () => {}); + + // Confirm listeners were added + expect(mi2.listenerCount('msg')).toBeGreaterThan(0); + expect(mi2.listenerCount('debug-ready')).toBeGreaterThan(0); + + // Remove all listeners + mi2.removeAllListeners(); + + // All listener counts must be 0 + expect(mi2.listenerCount('msg')).toBe(0); + expect(mi2.listenerCount('debug-ready')).toBe(0); + }); +}); diff --git a/__tests__/backend/rtos-detector.test.ts b/__tests__/backend/rtos-detector.test.ts new file mode 100644 index 0000000..e31d8f5 --- /dev/null +++ b/__tests__/backend/rtos-detector.test.ts @@ -0,0 +1,152 @@ +import { + FreeRTOSThreadParser, + RTOSDetector, + RTOSManager, + RTOSType, + ThreadXThreadParser, + ZephyrThreadParser, +} from '../../src/backend/rtos' + +function createReader(values: Record) { + return { + evalExpression: jest.fn().mockImplementation((expression: string) => { + if (!(expression in values)) { + return Promise.reject(new Error(`missing ${expression}`)) + } + return Promise.resolve({ + result: (path: string) => (path === 'value' ? values[expression] : undefined), + }) + }), + } +} + +describe('RTOSDetector', () => { + test('detects FreeRTOS using pxCurrentTCB symbol', async () => { + const detector = new RTOSDetector() + const reader = createReader({ '&pxCurrentTCB': '0x20000000' }) + + await expect(detector.detect(reader as any)).resolves.toBe(RTOSType.FreeRTOS) + }) + + test('detects ThreadX using _tx_thread_current_ptr symbol', async () => { + const detector = new RTOSDetector() + const reader = createReader({ '&_tx_thread_current_ptr': '0x24000000' }) + + await expect(detector.detect(reader as any)).resolves.toBe(RTOSType.ThreadX) + }) + + test('detects Zephyr using _kernel.current symbol', async () => { + const detector = new RTOSDetector() + const reader = createReader({ '_kernel.current': '0x20003000' }) + + await expect(detector.detect(reader as any)).resolves.toBe(RTOSType.Zephyr) + }) + + test('returns none when no RTOS markers are available', async () => { + const detector = new RTOSDetector() + const reader = { + evalExpression: jest.fn().mockRejectedValue(new Error('missing')), + } + + await expect(detector.detect(reader as any)).resolves.toBe(RTOSType.None) + }) +}) + +describe('RTOS parsers', () => { + test('parses the current FreeRTOS task metadata', async () => { + const parser = new FreeRTOSThreadParser() + const reader = createReader({ + pxCurrentTCB: '0x20000000', + '((TCB_t *)pxCurrentTCB)->pcTaskName': '"IdleTask"', + '((TCB_t *)pxCurrentTCB)->uxPriority': '3', + '((TCB_t *)pxCurrentTCB)->eCurrentState': '0', + '((TCB_t *)pxCurrentTCB)->pxTopOfStack': '0x20001000', + '((TCB_t *)pxCurrentTCB)->pxStack': '0x20000000', + '((TCB_t *)pxCurrentTCB)->pxEndOfStack': '0x20002000', + }) + + const threads = await parser.parseThreads(reader as any, { currentGdbThreadId: 7 }) + + expect(threads).toHaveLength(1) + expect(threads[0]).toMatchObject({ + id: 7, + gdbThreadId: 7, + name: 'IdleTask', + priority: 3, + state: 'running', + source: RTOSType.FreeRTOS, + }) + expect(threads[0].stackInfo).toMatchObject({ + base: 0x20000000, + size: 0x2000, + used: 0x1000, + }) + }) + + test('parses ThreadX current thread metadata', async () => { + const parser = new ThreadXThreadParser() + const reader = createReader({ + _tx_thread_current_ptr: '0x24000000', + '_tx_thread_current_ptr->tx_thread_name': '"control"', + '_tx_thread_current_ptr->tx_thread_priority': '9', + '_tx_thread_current_ptr->tx_thread_state': '4', + '_tx_thread_current_ptr->tx_thread_stack_ptr': '0x24001000', + '_tx_thread_current_ptr->tx_thread_stack_start': '0x24000000', + '_tx_thread_current_ptr->tx_thread_stack_end': '0x24002000', + }) + + const threads = await parser.parseThreads(reader as any, { currentGdbThreadId: 2 }) + + expect(threads[0]).toMatchObject({ + id: 2, + name: 'control', + priority: 9, + state: 'blocked', + source: RTOSType.ThreadX, + }) + }) + + test('parses Zephyr current thread metadata', async () => { + const parser = new ZephyrThreadParser() + const reader = createReader({ + '_kernel.current': '0x20003000', + '((struct k_thread *)_kernel.current)->name': '"shell"', + '((struct k_thread *)_kernel.current)->base.prio': '-1', + '((struct k_thread *)_kernel.current)->base.thread_state': '0', + '((struct k_thread *)_kernel.current)->callee_saved.psp': '0x20003100', + }) + + const threads = await parser.parseThreads(reader as any, { currentGdbThreadId: 4 }) + + expect(threads[0]).toMatchObject({ + id: 4, + name: 'shell', + priority: -1, + state: 'running', + source: RTOSType.Zephyr, + }) + }) +}) + +describe('RTOSManager', () => { + test('respects explicit RTOS type selection', async () => { + const manager = new RTOSManager() + const reader = createReader({ + _tx_thread_current_ptr: '0x24000000', + '_tx_thread_current_ptr->tx_thread_name': '"worker"', + '_tx_thread_current_ptr->tx_thread_priority': '2', + '_tx_thread_current_ptr->tx_thread_state': '3', + '_tx_thread_current_ptr->tx_thread_stack_ptr': '0x24000100', + '_tx_thread_current_ptr->tx_thread_stack_start': '0x24000000', + '_tx_thread_current_ptr->tx_thread_stack_end': '0x24001000', + }) + + const result = await manager.load(reader as any, { + requestedType: 'threadx', + currentGdbThreadId: 5, + }) + + expect(result.type).toBe(RTOSType.ThreadX) + expect(result.threads[0]).toMatchObject({ id: 5, name: 'worker' }) + }) +}) \ No newline at end of file diff --git a/__tests__/backend/rtos-thread-mapping.test.ts b/__tests__/backend/rtos-thread-mapping.test.ts new file mode 100644 index 0000000..ffdb841 --- /dev/null +++ b/__tests__/backend/rtos-thread-mapping.test.ts @@ -0,0 +1,110 @@ +import { GDBDebugSession } from '../../src/backend/adapter' + +function makeThreadInfo(id: number, label: string) { + return [ + [ + 'id', + String(id), + ], + [ + 'target-id', + `Thread ${id}`, + ], + [ + 'details', + label, + ], + ] +} + +describe('RTOS thread mapping', () => { + test('threadsRequest enriches thread labels with RTOS metadata', async () => { + const session = new GDBDebugSession() as any + session.stopped = true + session.currentThreadId = 2 + session.args = { rtos: { enabled: true, type: 'auto' } } + session.miDebugger = { + sendCommand: jest.fn().mockImplementation((command: string) => { + if (command === 'thread-list-ids') { + return Promise.resolve({ + result: (path: string) => { + if (path === 'thread-ids') return [['id', '1'], ['id', '2']] + if (path === 'current-thread-id') return '2' + return undefined + }, + }) + } + + if (command === 'thread-info 1') { + return Promise.resolve({ result: (path: string) => (path === 'threads' ? [makeThreadInfo(1, 'main')] : undefined) }) + } + + if (command === 'thread-info 2') { + return Promise.resolve({ result: (path: string) => (path === 'threads' ? [makeThreadInfo(2, 'worker')] : undefined) }) + } + + return Promise.reject(new Error(`unexpected ${command}`)) + }), + } + session.rtosManager = { + load: jest.fn().mockResolvedValue({ + type: 'freertos', + threads: [ + { + id: 2, + gdbThreadId: 2, + name: 'IdleTask', + state: 'running', + priority: 1, + source: 'freertos', + }, + ], + }), + } + session.sendResponse = jest.fn() + session.sendErrorResponse = jest.fn() + + const response: any = {} + await session.threadsRequest(response) + + expect(response.body.threads).toHaveLength(2) + + // Order-independent: locate threads by id + const mainThread = response.body.threads.find((t: any) => t.id === 1) + const idleThread = response.body.threads.find((t: any) => t.id === 2) + + expect(mainThread).toMatchObject({ id: 1, name: 'main' }) + expect(idleThread).toBeDefined() + expect(idleThread.name).toContain('IdleTask') + expect(idleThread.name).toContain('running') + expect(idleThread.name).toContain('prio 1') + }) + + test('stackTraceRequest resolves DAP thread ids through the RTOS map', async () => { + const session = new GDBDebugSession() as any + session.miDebugger = { + getStack: jest.fn().mockResolvedValue([ + { + level: '0', + address: '0x1000', + function: 'main', + fileName: 'main.c', + file: '/tmp/main.c', + line: 42, + }, + ]), + } + session.checkFileExists = jest.fn().mockResolvedValue(true) + session.symbolTable = { getFunctionByName: jest.fn().mockReturnValue(undefined) } + session.dapThreadIdMap = new Map([[1001, 2]]) + session.sendResponse = jest.fn() + session.sendErrorResponse = jest.fn() + + const response: any = {} + await session.stackTraceRequest(response, { threadId: 1001, startFrame: 0, levels: 20 }) + + expect(session.miDebugger.getStack).toHaveBeenCalledWith(2, 0, 20) + expect(response.body.stackFrames).toHaveLength(1) + expect(response.body.stackFrames[0].name).toBe('main@0x1000') + }) +}) \ No newline at end of file diff --git a/__tests__/diagnostics/diagnostics-manager.test.ts b/__tests__/diagnostics/diagnostics-manager.test.ts new file mode 100644 index 0000000..966d8d2 --- /dev/null +++ b/__tests__/diagnostics/diagnostics-manager.test.ts @@ -0,0 +1,354 @@ +import { + DiagnosticsManager, + ErrorAction, + LogEntry, + getDiagnosticsManager, + resetDiagnosticsManager +} from '../../src/frontend/diagnostics' + +// Mock vscode - factory function to avoid hoisting issues +jest.mock('vscode', () => { + const mockThenable = { + then: jest.fn(function(this: any, callback?: (value: any) => any) { + if (callback) callback(undefined) + return this + }), + catch: jest.fn().mockReturnThis() + } + + const mockOutputChannel = { + appendLine: jest.fn(), + clear: jest.fn(), + show: jest.fn() + } + + return { + window: { + createOutputChannel: jest.fn().mockReturnValue(mockOutputChannel), + showErrorMessage: jest.fn().mockReturnValue(mockThenable), + showWarningMessage: jest.fn().mockReturnValue(mockThenable), + showInformationMessage: jest.fn().mockReturnValue(mockThenable), + showOpenDialog: jest.fn().mockResolvedValue(undefined) + }, + env: { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined) + }, + openExternal: jest.fn().mockResolvedValue(true) + }, + Uri: { + parse: jest.fn() + }, + commands: { + executeCommand: jest.fn().mockResolvedValue(undefined) + }, + __mockOutputChannel: mockOutputChannel + } +}) + +import * as vscode from 'vscode' + +describe('DiagnosticsManager', () => { + let manager: DiagnosticsManager + + beforeEach(() => { + resetDiagnosticsManager() + manager = getDiagnosticsManager() + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + describe('Singleton Pattern', () => { + test('getDiagnosticsManager should return same instance', () => { + const instance1 = getDiagnosticsManager() + const instance2 = getDiagnosticsManager() + expect(instance1).toBe(instance2) + }) + + test('resetDiagnosticsManager should create new instance on next get', () => { + const instance1 = getDiagnosticsManager() + resetDiagnosticsManager() + const instance2 = getDiagnosticsManager() + expect(instance1).not.toBe(instance2) + }) + }) + + describe('Logging', () => { + test('should log error message', () => { + manager.error('TestSource', 'Test error message', 'Details') + const entries = manager.getLogEntries('error') + expect(entries.length).toBe(1) + expect(entries[0].source).toBe('TestSource') + expect(entries[0].message).toBe('Test error message') + expect(entries[0].details).toBe('Details') + expect(entries[0].level).toBe('error') + }) + + test('should log warning message', () => { + manager.warn('TestSource', 'Test warning') + const entries = manager.getLogEntries('warn') + expect(entries.length).toBe(1) + expect(entries[0].level).toBe('warn') + }) + + test('should log info message', () => { + manager.info('TestSource', 'Test info') + const entries = manager.getLogEntries('info') + expect(entries.length).toBe(1) + expect(entries[0].level).toBe('info') + }) + + test('should log debug message when dev output enabled', () => { + manager.setShowDevDebugOutput(true) + manager.debug('TestSource', 'Test debug') + const entries = manager.getLogEntries('debug') + expect(entries.length).toBe(1) + expect(entries[0].level).toBe('debug') + }) + + test('should not log debug message when dev output disabled', () => { + manager.setShowDevDebugOutput(false) + manager.debug('TestSource', 'Test debug') + const entries = manager.getLogEntries('debug') + expect(entries.length).toBe(1) // Still stored, just not displayed + }) + + test('should maintain log size limit', () => { + // Add 1001 entries (limit is 1000) + for (let i = 0; i < 1001; i++) { + manager.info('Test', `Message ${i}`) + } + const entries = manager.getLogEntries() + expect(entries.length).toBe(1000) + // First entry should be removed + expect(entries[0].message).toBe('Message 1') + }) + + test('clearLog should remove all entries', () => { + manager.info('Test', 'Message 1') + manager.info('Test', 'Message 2') + manager.clearLog() + const entries = manager.getLogEntries() + expect(entries.length).toBe(0) + }) + }) + + describe('Log Filtering', () => { + beforeEach(() => { + manager.error('Source1', 'Error message') + manager.warn('Source2', 'Warning message') + manager.info('Source3', 'Info message') + manager.debug('Source4', 'Debug message') + }) + + test('should filter by error level', () => { + const entries = manager.getLogEntries('error') + expect(entries.length).toBe(1) + expect(entries[0].level).toBe('error') + }) + + test('should return all entries when no filter', () => { + const entries = manager.getLogEntries() + expect(entries.length).toBe(4) + }) + }) + + describe('Log Export', () => { + test('should export log in correct format', () => { + manager.error('TestSource', 'Test message', 'Test details') + const exported = manager.exportLog() + + expect(exported).toContain('PlatformIO Debug Diagnostic Log') + expect(exported).toContain('Total Entries: 1') + expect(exported).toContain('[ERROR]') + expect(exported).toContain('TestSource') + expect(exported).toContain('Test message') + }) + }) + + describe('Error Display', () => { + test('showError should call vscode.window.showErrorMessage', () => { + manager.showError('Test error') + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith('Test error') + }) + + test('showError with actions should include action labels', () => { + const actions: ErrorAction[] = [ + { label: 'Action 1', callback: jest.fn() }, + { label: 'Action 2', callback: jest.fn() } + ] + manager.showError('Test error', actions) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + 'Test error', + 'Action 1', + 'Action 2' + ) + }) + + test('showWarning should call vscode.window.showWarningMessage', () => { + manager.showWarning('Test warning') + expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('Test warning') + }) + + test('showInfo should call vscode.window.showInformationMessage', () => { + manager.showInfo('Test info') + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith('Test info') + }) + }) + + describe('GDB Error Pattern Matching', () => { + test('should handle connection refused error', () => { + const handled = manager.handleGDBError('Connection refused') + expect(handled).toBe(true) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + test('should handle connection timeout error', () => { + const handled = manager.handleGDBError('Connection timed out') + expect(handled).toBe(true) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + test('should handle file not found error', () => { + const handled = manager.handleGDBError('No such file or directory') + expect(handled).toBe(true) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + test('should handle memory access error', () => { + const handled = manager.handleGDBError('Cannot access memory') + expect(handled).toBe(true) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + + test('should handle remote replied with error', () => { + const handled = manager.handleGDBError('Remote replied with error') + expect(handled).toBe(true) + // This is a warning, not error + expect(vscode.window.showWarningMessage).toHaveBeenCalled() + }) + + test('should handle unrecognized command', () => { + const handled = manager.handleGDBError('Unrecognized command') + expect(handled).toBe(true) + expect(vscode.window.showWarningMessage).toHaveBeenCalled() + }) + + test('should show generic error for unmatched pattern', () => { + const handled = manager.handleGDBError('Some random error') + expect(handled).toBe(false) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + // Should include diagnostic actions + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + expect(call.length).toBeGreaterThan(1) // Has action labels + }) + }) + + describe('Connection Error Handling', () => { + test('should show connection error with troubleshooting', () => { + manager.handleConnectionError('Connection refused', 'localhost', 3333) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + expect(call[0]).toContain('localhost:3333') + // Should have multiple actions + expect(call.length).toBeGreaterThan(2) + }) + + test('should show connection error without host/port', () => { + manager.handleConnectionError('Connection failed') + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + expect(call[0]).not.toContain('undefined') + }) + }) + + describe('SVD Error Handling', () => { + test('should show SVD error with path', () => { + manager.handleSVDError('Parse error', '/path/to/device.svd') + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + expect(call[0]).toContain('/path/to/device.svd') + }) + + test('should show SVD error without path', () => { + manager.handleSVDError('Parse error') + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + expect(call[0]).toBe('Failed to load SVD file') + }) + + test('SVD error should have locate file action', () => { + manager.handleSVDError('Parse error') + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + // Check that action labels are present + const labels = call.slice(1) + expect(labels).toContain('Locate SVD File') + expect(labels).toContain('Skip SVD Load') + }) + }) + + describe('Memory Error Handling', () => { + test('should show memory error with address', () => { + manager.handleMemoryError('Access denied', 0x20000000) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + expect(call[0]).toContain('0x20000000') + }) + + test('should show memory error without address', () => { + manager.handleMemoryError('Access denied') + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + expect(call[0]).toBe('Cannot access memory') + }) + + test('memory error should have pause target action', () => { + manager.handleMemoryError('Access denied', 0x20000000) + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + const labels = call.slice(1) + expect(labels).toContain('Target May Not Be Halted') + expect(labels).toContain('Check Address') + }) + }) + + describe('Output Channel', () => { + test('showOutputChannel should call output channel show', () => { + const mockOutputChannel = (vscode as any).__mockOutputChannel + manager.showOutputChannel() + expect(mockOutputChannel.show).toHaveBeenCalled() + }) + }) + + describe('Action Callbacks', () => { + test('error action callback should be executed when selected', async () => { + const callback = jest.fn() + const actions: ErrorAction[] = [ + { label: 'Test Action', callback } + ] + + // Mock the promise resolution + ;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValue('Test Action') + + manager.showError('Test', actions) + + // Wait for promise to resolve + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(callback).toHaveBeenCalled() + }) + + test('info action should show documentation link', async () => { + // Mock the promise resolution for connection troubleshooting + ;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValue('Check Connection') + + manager.handleConnectionError('Error', 'host', 1234) + + // Wait for promise chain + await new Promise(resolve => setTimeout(resolve, 0)) + + expect(vscode.window.showInformationMessage).toHaveBeenCalled() + }) + }) +}) diff --git a/__tests__/diagnostics/error-pattern-matching.test.ts b/__tests__/diagnostics/error-pattern-matching.test.ts new file mode 100644 index 0000000..87f6ce6 --- /dev/null +++ b/__tests__/diagnostics/error-pattern-matching.test.ts @@ -0,0 +1,216 @@ +import { getDiagnosticsManager, resetDiagnosticsManager } from '../../src/frontend/diagnostics' + +// Mock vscode - factory function to avoid hoisting issues +jest.mock('vscode', () => { + const mockThenable = { + then: jest.fn(function(this: any, callback?: (value: any) => any) { + if (callback) callback(undefined) + return this + }), + catch: jest.fn().mockReturnThis() + } + + return { + window: { + createOutputChannel: jest.fn().mockReturnValue({ + appendLine: jest.fn(), + clear: jest.fn(), + show: jest.fn() + }), + showErrorMessage: jest.fn().mockReturnValue(mockThenable), + showWarningMessage: jest.fn().mockReturnValue(mockThenable), + showInformationMessage: jest.fn().mockReturnValue(mockThenable) + }, + env: { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined) + }, + openExternal: jest.fn().mockResolvedValue(true) + }, + Uri: { + parse: jest.fn() + }, + commands: { + executeCommand: jest.fn().mockResolvedValue(undefined) + } + } +}) + +import * as vscode from 'vscode' + +describe('Error Pattern Matching', () => { + let manager: ReturnType + + beforeEach(() => { + resetDiagnosticsManager() + manager = getDiagnosticsManager() + jest.clearAllMocks() + }) + + describe('Connection Errors', () => { + const connectionErrors = [ + 'Connection refused', + 'Connection failed', + 'Connection timed out', + 'connection refused by peer', + 'CONNECTION FAILED' + ] + + connectionErrors.forEach(error => { + test(`should match "${error}"`, () => { + const handled = manager.handleGDBError(error) + expect(handled).toBe(true) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + }) + }) + }) + + describe('File Not Found Errors', () => { + const fileErrors = [ + 'No such file or directory' + ] + + fileErrors.forEach(error => { + test(`should match "${error}"`, () => { + const handled = manager.handleGDBError(error) + expect(handled).toBe(true) + }) + }) + }) + + describe('Memory Access Errors', () => { + const memoryErrors = [ + 'Cannot access memory', + 'cannot access memory at address 0x20000000' + ] + + memoryErrors.forEach(error => { + test(`should match "${error}"`, () => { + const handled = manager.handleGDBError(error) + expect(handled).toBe(true) + }) + }) + }) + + describe('Remote Protocol Errors', () => { + const remoteErrors = [ + 'Remote replied with error' + ] + + remoteErrors.forEach(error => { + test(`should match "${error}"`, () => { + const handled = manager.handleGDBError(error) + expect(handled).toBe(true) + // These should show as warnings + expect(vscode.window.showWarningMessage).toHaveBeenCalled() + }) + }) + }) + + describe('Command Errors', () => { + const commandErrors = [ + 'Unrecognized command' + ] + + commandErrors.forEach(error => { + test(`should match "${error}"`, () => { + const handled = manager.handleGDBError(error) + expect(handled).toBe(true) + }) + }) + }) + + describe('Unmatched Errors', () => { + const unmatchedErrors = [ + 'Something unexpected happened', + 'Random error message', + 'Custom user error' + ] + + unmatchedErrors.forEach(error => { + test(`should not match "${error}" and show generic error`, () => { + const handled = manager.handleGDBError(error) + expect(handled).toBe(false) + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + // Should include actions + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + expect(call.length).toBeGreaterThan(1) + }) + }) + }) + + describe('Error Severity Classification', () => { + test('connection errors should be classified as error severity', () => { + manager.handleGDBError('Connection refused') + expect(vscode.window.showErrorMessage).toHaveBeenCalled() + expect(vscode.window.showWarningMessage).not.toHaveBeenCalled() + }) + + test('remote protocol errors should be classified as warning severity', () => { + manager.handleGDBError('Remote replied with error') + expect(vscode.window.showWarningMessage).toHaveBeenCalled() + expect(vscode.window.showErrorMessage).not.toHaveBeenCalled() + }) + }) + + describe('Error Message Content', () => { + test('should include user-friendly message for connection errors', () => { + manager.handleGDBError('Connection refused') + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + expect(call[0]).toContain('target') + expect(call[0]).toContain('connected') + }) + + test('should include user-friendly message for file errors', () => { + manager.handleGDBError('No such file or directory') + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + expect(call[0]).toContain('configuration') + }) + + test('should include user-friendly message for memory errors', () => { + manager.handleGDBError('Cannot access memory') + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + expect(call[0]).toContain('halted') + }) + }) + + describe('Action Labels by Error Type', () => { + test('connection errors should have Check Connection action', () => { + manager.handleGDBError('Connection refused') + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + const labels = call.slice(1) + expect(labels).toContain('Check Connection') + }) + + test('memory errors should have Pause Target action', () => { + manager.handleGDBError('Cannot access memory') + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + const labels = call.slice(1) + expect(labels).toContain('Pause Target') + }) + + test('connection errors should have Retry action', () => { + manager.handleGDBError('Connection refused') + const call = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + const labels = call.slice(1) + expect(labels).toContain('Retry') + }) + }) + + describe('Case Insensitivity', () => { + test('should match errors regardless of case', () => { + const variations = [ + 'CONNECTION REFUSED', + 'connection refused', + 'Connection Refused', + 'CoNnEcTiOn ReFuSeD' + ] + + variations.forEach(error => { + const handled = manager.handleGDBError(error) + expect(handled).toBe(true) + jest.clearAllMocks() + }) + }) + }) +}) diff --git a/__tests__/diagnostics/troubleshooting-wizard.test.ts b/__tests__/diagnostics/troubleshooting-wizard.test.ts new file mode 100644 index 0000000..7c727d0 --- /dev/null +++ b/__tests__/diagnostics/troubleshooting-wizard.test.ts @@ -0,0 +1,283 @@ +/** + * Tests for the connection troubleshooting wizard diagnostic flow. + * + * Verifies that the domain-specific error handlers produce the right + * error messages and actionable recovery steps, and that action callbacks + * trigger the expected VS Code commands. + */ + +import { getDiagnosticsManager, resetDiagnosticsManager } from '../../src/frontend/diagnostics' + +// Capture the action callbacks passed to showErrorMessage so we can invoke them +let capturedActions: Array<{ label: string; callback: () => void }> = [] + +jest.mock('vscode', () => { + // Track the last set of ErrorAction labels that were shown + const capturedLabels: string[] = [] + + return { + window: { + createOutputChannel: jest.fn().mockReturnValue({ + appendLine: jest.fn(), + clear: jest.fn(), + show: jest.fn(), + }), + // Resolve with the first action label to simulate the user clicking it + showErrorMessage: jest.fn().mockImplementation((_msg: string, ...labels: string[]) => { + capturedLabels.splice(0, capturedLabels.length, ...labels) + return Promise.resolve(labels[0]) + }), + showWarningMessage: jest.fn().mockImplementation((_msg: string, ...labels: string[]) => + Promise.resolve(labels[0]) + ), + showInformationMessage: jest.fn().mockImplementation((_msg: string, ...labels: string[]) => + Promise.resolve(labels[0]) + ), + showOpenDialog: jest.fn().mockResolvedValue([{ fsPath: '/path/to/file.svd' }]), + }, + env: { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + openExternal: jest.fn().mockResolvedValue(true), + }, + Uri: { + parse: jest.fn((s: string) => ({ toString: () => s })), + }, + commands: { + executeCommand: jest.fn().mockResolvedValue(undefined), + }, + } +}) + +import * as vscode from 'vscode' + +// ------------------------------------------------------------------------- +// Helpers +// ------------------------------------------------------------------------- + +function getManager() { + return getDiagnosticsManager() +} + +// ------------------------------------------------------------------------- +// Test suites +// ------------------------------------------------------------------------- + +describe('Troubleshooting Wizard — handleConnectionError', () => { + beforeEach(() => { + resetDiagnosticsManager() + jest.clearAllMocks() + capturedActions = [] + }) + + test('shows an error message mentioning the host:port when provided', () => { + getManager().handleConnectionError('refused', 'localhost', 3333) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('localhost:3333'), + expect.any(String), + expect.any(String), + expect.any(String) + ) + }) + + test('shows an error message without host:port when omitted', () => { + getManager().handleConnectionError('refused') + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('debug server'), + expect.any(String), + expect.any(String), + expect.any(String) + ) + }) + + test('offers "Check Connection", "Restart Debug" and "Show Diagnostics" actions', () => { + getManager().handleConnectionError('timed out', '192.168.1.1', 2331) + const callArgs = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + const actionLabels: string[] = callArgs.slice(1) + expect(actionLabels).toContain('Check Connection') + expect(actionLabels).toContain('Restart Debug') + expect(actionLabels).toContain('Show Diagnostics') + }) + + test('"Restart Debug" action triggers workbench.action.debug.restart command', async () => { + // The mock resolves with the first label; simulate selecting "Restart Debug" by + // overriding the mock for this specific call. + ;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValueOnce('Restart Debug') + getManager().handleConnectionError('refused', 'localhost', 3333) + // Allow the promise chain to settle + await Promise.resolve() + await Promise.resolve() + expect(vscode.commands.executeCommand).toHaveBeenCalledWith( + 'workbench.action.debug.restart' + ) + }) + + test('logs the error at "error" level', () => { + const mgr = getManager() + mgr.handleConnectionError('refused', 'localhost', 3333) + const errors = mgr.getLogEntries('error') + expect(errors.length).toBeGreaterThan(0) + expect(errors[0].source).toBe('Connection') + }) +}) + +// ------------------------------------------------------------------------- + +describe('Troubleshooting Wizard — handleSVDError', () => { + beforeEach(() => { + resetDiagnosticsManager() + jest.clearAllMocks() + }) + + test('includes the SVD path in the error message when provided', () => { + getManager().handleSVDError('parse failed', '/project/device.svd') + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('device.svd'), + expect.any(String), + expect.any(String) + ) + }) + + test('shows a generic message when no path is provided', () => { + getManager().handleSVDError('not found') + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('SVD file'), + expect.any(String), + expect.any(String) + ) + }) + + test('offers "Locate SVD File" and "Skip SVD Load" actions', () => { + getManager().handleSVDError('invalid', '/bad.svd') + const callArgs = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + const actionLabels: string[] = callArgs.slice(1) + expect(actionLabels).toContain('Locate SVD File') + expect(actionLabels).toContain('Skip SVD Load') + }) + + test('"Locate SVD File" action opens a file dialog and reloads SVD', async () => { + ;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValueOnce('Locate SVD File') + getManager().handleSVDError('bad', '/bad.svd') + await Promise.resolve() + await Promise.resolve() + expect(vscode.window.showOpenDialog).toHaveBeenCalledWith( + expect.objectContaining({ canSelectFiles: true }) + ) + }) + + test('logs the error at "error" level with SVD source', () => { + const mgr = getManager() + mgr.handleSVDError('corrupt', '/a.svd') + const errors = mgr.getLogEntries('error') + expect(errors.length).toBeGreaterThan(0) + expect(errors[0].source).toBe('SVD') + }) +}) + +// ------------------------------------------------------------------------- + +describe('Troubleshooting Wizard — handleMemoryError', () => { + beforeEach(() => { + resetDiagnosticsManager() + jest.clearAllMocks() + }) + + test('includes the hex address in the message when provided', () => { + getManager().handleMemoryError('fault', 0x20000000) + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringMatching(/20000000/i), + expect.any(String), + expect.any(String) + ) + }) + + test('shows a generic message when address is omitted', () => { + getManager().handleMemoryError('fault') + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + expect.stringContaining('memory'), + expect.any(String), + expect.any(String) + ) + }) + + test('offers "Target May Not Be Halted" and "Check Address" actions', () => { + getManager().handleMemoryError('access denied', 0x00) + const callArgs = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + const actionLabels: string[] = callArgs.slice(1) + expect(actionLabels).toContain('Target May Not Be Halted') + expect(actionLabels).toContain('Check Address') + }) + + test('logs the error at "error" level with Memory source', () => { + const mgr = getManager() + mgr.handleMemoryError('bus error', 0x40000000) + const errors = mgr.getLogEntries('error') + expect(errors.length).toBeGreaterThan(0) + expect(errors[0].source).toBe('Memory') + }) +}) + +// ------------------------------------------------------------------------- + +describe('Troubleshooting Wizard — handleGDBError generic fallback', () => { + beforeEach(() => { + resetDiagnosticsManager() + jest.clearAllMocks() + }) + + test('returns false and offers "Show Diagnostics" and "Copy Error" for unknown errors', () => { + const handled = getManager().handleGDBError('some unknown gdb error xyz') + expect(handled).toBe(false) + const callArgs = (vscode.window.showErrorMessage as jest.Mock).mock.calls[0] + const actionLabels: string[] = callArgs.slice(1) + expect(actionLabels).toContain('Show Diagnostics') + expect(actionLabels).toContain('Copy Error') + }) + + test('"Copy Error" action copies the error message to the clipboard', async () => { + ;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValueOnce('Copy Error') + getManager().handleGDBError('unknown fatal error') + await Promise.resolve() + await Promise.resolve() + expect(vscode.env.clipboard.writeText).toHaveBeenCalledWith( + expect.stringContaining('unknown fatal error') + ) + }) + + test('"Show Diagnostics" action opens the output channel', async () => { + ;(vscode.window.showErrorMessage as jest.Mock).mockResolvedValueOnce('Show Diagnostics') + // Create the manager first so createOutputChannel has been called + const mgr = getManager() + const mockChannel = (vscode.window.createOutputChannel as jest.Mock).mock.results[0].value + mgr.handleGDBError('unknown error') + await Promise.resolve() + await Promise.resolve() + expect(mockChannel.show).toHaveBeenCalled() + }) +}) + +// ------------------------------------------------------------------------- + +describe('Troubleshooting Wizard — exportLog', () => { + beforeEach(() => { + resetDiagnosticsManager() + }) + + test('exported log contains all logged entries', () => { + const mgr = getManager() + mgr.error('SrcA', 'msg1') + mgr.warn('SrcB', 'msg2') + mgr.info('SrcC', 'msg3') + const log = mgr.exportLog() + expect(log).toContain('msg1') + expect(log).toContain('msg2') + expect(log).toContain('msg3') + }) + + test('exported log contains a header with entry count', () => { + const mgr = getManager() + mgr.error('S', 'e1') + mgr.error('S', 'e2') + const log = mgr.exportLog() + expect(log).toContain('Total Entries: 2') + }) +}) diff --git a/__tests__/frontend/memory-content-provider.test.ts b/__tests__/frontend/memory-content-provider.test.ts new file mode 100644 index 0000000..8c34491 --- /dev/null +++ b/__tests__/frontend/memory-content-provider.test.ts @@ -0,0 +1,287 @@ +import { MemoryContentProvider, MemoryDataType, Endianness } from '../../src/frontend/memory_content_provider' + +// Mock vscode +jest.mock('vscode', () => ({ + window: { + createTextEditorDecorationType: jest.fn().mockReturnValue({ + dispose: jest.fn() + }), + showErrorMessage: jest.fn().mockResolvedValue(undefined), + showInformationMessage: jest.fn().mockResolvedValue(undefined) + }, + debug: { + activeDebugSession: undefined + }, + EventEmitter: jest.fn().mockImplementation(() => ({ + event: jest.fn(), + fire: jest.fn() + })), + Position: jest.fn().mockImplementation((line: number, character: number) => ({ + line, + character + })), + Range: jest.fn().mockImplementation((start: any, end: any) => { + // Handle both Position objects and (line, char) numbers + const startLine = typeof start === 'number' ? start : start?.line ?? 0 + const startChar = typeof start === 'number' ? end : start?.character ?? 0 + const endLine = typeof end === 'number' ? end : end?.line ?? 0 + const endChar = typeof end === 'number' ? 0 : end?.character ?? 0 + + return { + start: { line: startLine, character: startChar }, + end: { line: endLine, character: endChar } + } + }), + OverviewRulerLane: { + Right: 4 + }, + Uri: { + parse: jest.fn() + }, + workspace: { + textDocuments: [] + }, + env: { + clipboard: { + writeText: jest.fn().mockResolvedValue(undefined) + } + } +})) + +describe('MemoryContentProvider', () => { + let provider: MemoryContentProvider + + beforeEach(() => { + provider = new MemoryContentProvider() + }) + + describe('Display-setting isolation between provider instances', () => { + test('setting dataType on providerA does not affect providerB', () => { + const providerA = new MemoryContentProvider() + const providerB = new MemoryContentProvider() + + const allTypes = [ + MemoryDataType.U8, + MemoryDataType.U16, + MemoryDataType.U32, + MemoryDataType.U64, + MemoryDataType.I8, + MemoryDataType.I16, + MemoryDataType.I32, + MemoryDataType.I64, + MemoryDataType.Float, + MemoryDataType.Double, + ] + + for (const type of allTypes) { + providerA.setDataType(type) + // providerB must remain at its own default regardless of providerA + expect(providerB.getDataType()).toBe(MemoryDataType.U8) + } + + // After resetting providerA to Float, providerB still at U8 + providerA.setDataType(MemoryDataType.Float) + expect(providerB.getDataType()).toBe(MemoryDataType.U8) + }) + + test('endianness changes on providerA do not affect providerB', () => { + const providerA = new MemoryContentProvider() + const providerB = new MemoryContentProvider() + + // Both default to Little + expect(providerA.getEndianness()).toBe(Endianness.Little) + expect(providerB.getEndianness()).toBe(Endianness.Little) + + providerA.setEndianness(Endianness.Big) + expect(providerA.getEndianness()).toBe(Endianness.Big) + expect(providerB.getEndianness()).toBe(Endianness.Little) + + providerA.toggleEndianness() + expect(providerA.getEndianness()).toBe(Endianness.Little) + expect(providerB.getEndianness()).toBe(Endianness.Little) + + providerB.setEndianness(Endianness.Big) + expect(providerA.getEndianness()).toBe(Endianness.Little) + expect(providerB.getEndianness()).toBe(Endianness.Big) + }) + + test('per-URI settings are isolated: changing URI A settings does not affect URI B fallback', () => { + const p = new MemoryContentProvider() + const uriA = 'examinememory://mem?address=0x20000000&length=0x10' + const uriB = 'examinememory://mem?address=0x30000000&length=0x10' + + // Set URI A to U32 / Big + p.setDataTypeForUri(uriA, MemoryDataType.U32) + p.toggleEndiannessForUri(uriA) + + // URI B must still return the global defaults (U8 / Little) + expect(p.getDataTypeForUri(uriB)).toBe(MemoryDataType.U8) + expect(p.getEndiannessForUri(uriB)).toBe(Endianness.Little) + + // URI A has its own values + expect(p.getDataTypeForUri(uriA)).toBe(MemoryDataType.U32) + expect(p.getEndiannessForUri(uriA)).toBe(Endianness.Big) + }) + }) + + describe('Data Type Settings', () => { + test('should default to U8 data type', () => { + expect(provider.getDataType()).toBe(MemoryDataType.U8) + }) + + test('should set and get data type', () => { + provider.setDataType(MemoryDataType.U32) + expect(provider.getDataType()).toBe(MemoryDataType.U32) + + provider.setDataType(MemoryDataType.Float) + expect(provider.getDataType()).toBe(MemoryDataType.Float) + }) + + test('should support all data types', () => { + const allTypes = [ + MemoryDataType.U8, + MemoryDataType.U16, + MemoryDataType.U32, + MemoryDataType.U64, + MemoryDataType.I8, + MemoryDataType.I16, + MemoryDataType.I32, + MemoryDataType.I64, + MemoryDataType.Float, + MemoryDataType.Double + ] + + allTypes.forEach(type => { + provider.setDataType(type) + expect(provider.getDataType()).toBe(type) + }) + }) + }) + + describe('Endianness Settings', () => { + test('should default to little endian', () => { + expect(provider.getEndianness()).toBe(Endianness.Little) + }) + + test('should set and get endianness', () => { + provider.setEndianness(Endianness.Big) + expect(provider.getEndianness()).toBe(Endianness.Big) + + provider.setEndianness(Endianness.Little) + expect(provider.getEndianness()).toBe(Endianness.Little) + }) + + test('should toggle endianness', () => { + // Start with little + expect(provider.getEndianness()).toBe(Endianness.Little) + + // Toggle to big + provider.toggleEndianness() + expect(provider.getEndianness()).toBe(Endianness.Big) + + // Toggle back to little + provider.toggleEndianness() + expect(provider.getEndianness()).toBe(Endianness.Little) + }) + }) + + describe('Position/Offset Mapping', () => { + test('should map byte offset to position', () => { + // First row, first byte (hex column) + const pos1 = provider.getPosition(0, false) + expect(pos1.line).toBe(2) + expect(pos1.character).toBe(10) // firstBytePos + + // First row, second byte + const pos2 = provider.getPosition(1, false) + expect(pos2.line).toBe(2) + expect(pos2.character).toBe(13) // 10 + 3 + + // Second row, first byte + const pos3 = provider.getPosition(16, false) + expect(pos3.line).toBe(3) + expect(pos3.character).toBe(10) + }) + + test('should map byte offset to ASCII position', () => { + const firstAsciiPos = (provider as any).firstAsciiPos as number + + // First row, first byte (ASCII column) + const pos1 = provider.getPosition(0, true) + expect(pos1.line).toBe(2) + expect(pos1.character).toBe(firstAsciiPos) + + // Second row, first byte in ASCII + const pos2 = provider.getPosition(16, true) + expect(pos2.line).toBe(3) + expect(pos2.character).toBe(firstAsciiPos) + }) + + test('should return undefined for invalid positions', () => { + // Header lines return undefined (clicking the header has no byte meaning) + const offset = provider.getOffset({ line: 0, character: 15 } as any) + expect(offset).toBeUndefined() + + // Character before first byte position + const offset2 = provider.getOffset({ line: 2, character: 5 } as any) + expect(offset2).toBeUndefined() + }) + + test('should calculate offset for hex column positions', () => { + // First byte position in hex column + const offset1 = provider.getOffset({ line: 2, character: 10 } as any) + expect(offset1).toBe(0) + + // Second byte (each byte takes 3 chars: "00 ") + const offset2 = provider.getOffset({ line: 2, character: 13 } as any) + expect(offset2).toBe(1) + + // Third byte + const offset3 = provider.getOffset({ line: 2, character: 16 } as any) + expect(offset3).toBe(2) + }) + + test('should treat the separator between hex and ASCII columns as invalid', () => { + expect(provider.getOffset({ line: 2, character: 58 } as any)).toBeUndefined() + expect(provider.getOffset({ line: 2, character: 59 } as any)).toBeUndefined() + }) + }) + + describe('Range Building', () => { + test('should build ranges for single byte', () => { + const ranges = provider.getRanges(0, 0, false) + expect(ranges.length).toBeGreaterThan(0) + expect(ranges[0].start.line).toBe(2) + }) + + test('should build ranges spanning multiple lines', () => { + // Bytes 0-20 span 2 lines (16 bytes per line) + const ranges = provider.getRanges(0, 20, false) + expect(ranges.length).toBe(2) + expect(ranges[0].start.line).toBe(2) + expect(ranges[1].start.line).toBe(3) + }) + }) +}) + +describe('MemoryDataType Enum', () => { + test('should have correct values', () => { + expect(MemoryDataType.U8).toBe('u8') + expect(MemoryDataType.U16).toBe('u16') + expect(MemoryDataType.U32).toBe('u32') + expect(MemoryDataType.U64).toBe('u64') + expect(MemoryDataType.I8).toBe('i8') + expect(MemoryDataType.I16).toBe('i16') + expect(MemoryDataType.I32).toBe('i32') + expect(MemoryDataType.I64).toBe('i64') + expect(MemoryDataType.Float).toBe('float') + expect(MemoryDataType.Double).toBe('double') + }) +}) + +describe('Endianness Enum', () => { + test('should have correct values', () => { + expect(Endianness.Little).toBe('little') + expect(Endianness.Big).toBe('big') + }) +}) diff --git a/__tests__/frontend/memory-data-types.test.ts b/__tests__/frontend/memory-data-types.test.ts new file mode 100644 index 0000000..6a3a148 --- /dev/null +++ b/__tests__/frontend/memory-data-types.test.ts @@ -0,0 +1,245 @@ +/** + * Tests for memory data type interpretation and endianness handling. + * These tests verify that values are correctly read from byte arrays + * with various data types and endianness settings. + */ + +import { MemoryContentProvider, MemoryDataType, Endianness } from '../../src/frontend/memory_content_provider' + +// Mock vscode +jest.mock('vscode', () => ({ + window: { + createTextEditorDecorationType: jest.fn().mockReturnValue({ + dispose: jest.fn() + }), + showErrorMessage: jest.fn().mockResolvedValue(undefined), + showInformationMessage: jest.fn().mockResolvedValue(undefined) + }, + debug: { + activeDebugSession: undefined + }, + EventEmitter: jest.fn().mockImplementation(() => ({ + event: jest.fn(), + fire: jest.fn() + })), + Position: jest.fn().mockImplementation((line: number, character: number) => ({ + line, + character + })), + Range: jest.fn(), + OverviewRulerLane: { + Right: 4 + }, + Uri: { + parse: jest.fn() + }, + workspace: { + textDocuments: [] + } +})) + +describe('Memory Data Type Interpretation', () => { + let provider: MemoryContentProvider + + beforeEach(() => { + provider = new MemoryContentProvider() + }) + + describe('Unsigned Integer Types', () => { + test('U8: should read single byte value', () => { + provider.setDataType(MemoryDataType.U8) + provider.setEndianness(Endianness.Little) + + // Single byte 0xAB = 171 + expect((provider as any).readValue([0xAB], 0, MemoryDataType.U8, Endianness.Little)).toBe(171) + // Single byte 0x00 = 0 + expect((provider as any).readValue([0x00], 0, MemoryDataType.U8, Endianness.Little)).toBe(0) + // Single byte 0xFF = 255 + expect((provider as any).readValue([0xFF], 0, MemoryDataType.U8, Endianness.Little)).toBe(255) + }) + + test('U16: should read 16-bit value little endian', () => { + provider.setDataType(MemoryDataType.U16) + provider.setEndianness(Endianness.Little) + + // Bytes [0x12, 0x34] in LE = 0x3412 = 13330 + expect((provider as any).readValue([0x12, 0x34], 0, MemoryDataType.U16, Endianness.Little)).toBe(0x3412) + + // Bytes [0x01, 0x00] in LE = 0x0001 = 1 + expect((provider as any).readValue([0x01, 0x00], 0, MemoryDataType.U16, Endianness.Little)).toBe(1) + + // Bytes [0x00, 0xFF] in LE = 0xFF00 = 65280 + expect((provider as any).readValue([0x00, 0xFF], 0, MemoryDataType.U16, Endianness.Little)).toBe(0xFF00) + }) + + test('U16: should read 16-bit value big endian', () => { + provider.setDataType(MemoryDataType.U16) + provider.setEndianness(Endianness.Big) + + // 0x1234 in BE: bytes [0x12, 0x34] = 0x1234 = 4660 + expect((provider as any).readValue([0x12, 0x34], 0, MemoryDataType.U16, Endianness.Big)).toBe(0x1234) + + // 0x0100 in BE: bytes [0x01, 0x00] = 0x0100 = 256 + expect((provider as any).readValue([0x01, 0x00], 0, MemoryDataType.U16, Endianness.Big)).toBe(0x0100) + }) + + test('U32: should read 32-bit value little endian', () => { + provider.setDataType(MemoryDataType.U32) + + // 0x78563412 in LE: bytes [0x12, 0x34, 0x56, 0x78] + const value = (provider as any).readValue([0x12, 0x34, 0x56, 0x78], 0, MemoryDataType.U32, Endianness.Little) + expect(value).toBe(0x78563412) + }) + + test('U32: should read 32-bit value big endian', () => { + provider.setDataType(MemoryDataType.U32) + + // 0x12345678 in BE: bytes [0x12, 0x34, 0x56, 0x78] + const value = (provider as any).readValue([0x12, 0x34, 0x56, 0x78], 0, MemoryDataType.U32, Endianness.Big) + expect(value).toBe(0x12345678) + }) + + test('U64: should read 64-bit value little endian', () => { + provider.setDataType(MemoryDataType.U64) + + // 0xEFCDAB8967452301 in LE + const bytes = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF] + const value = (provider as any).readValue(bytes, 0, MemoryDataType.U64, Endianness.Little) + expect(value).toBe(BigInt('0xEFCDAB8967452301')) + }) + + test('U64: should read 64-bit value big endian', () => { + provider.setDataType(MemoryDataType.U64) + + // 0x0123456789ABCDEF in BE + const bytes = [0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF] + const value = (provider as any).readValue(bytes, 0, MemoryDataType.U64, Endianness.Big) + expect(value).toBe(BigInt('0x0123456789ABCDEF')) + }) + }) + + describe('Signed Integer Types', () => { + test('I8: should read signed byte value', () => { + provider.setDataType(MemoryDataType.I8) + + // 0x7F = 127 (positive max) + expect((provider as any).readValue([0x7F], 0, MemoryDataType.I8, Endianness.Little)).toBe(127) + + // 0x80 = -128 (negative) + expect((provider as any).readValue([0x80], 0, MemoryDataType.I8, Endianness.Little)).toBe(-128) + + // 0xFF = -1 + expect((provider as any).readValue([0xFF], 0, MemoryDataType.I8, Endianness.Little)).toBe(-1) + + // 0x00 = 0 + expect((provider as any).readValue([0x00], 0, MemoryDataType.I8, Endianness.Little)).toBe(0) + }) + + test('I16: should read signed 16-bit value', () => { + provider.setDataType(MemoryDataType.I16) + + // 0x7FFF = 32767 (positive max) + expect((provider as any).readValue([0xFF, 0x7F], 0, MemoryDataType.I16, Endianness.Little)).toBe(32767) + + // 0x8000 = -32768 (negative) + expect((provider as any).readValue([0x00, 0x80], 0, MemoryDataType.I16, Endianness.Little)).toBe(-32768) + + // 0xFFFF = -1 + expect((provider as any).readValue([0xFF, 0xFF], 0, MemoryDataType.I16, Endianness.Little)).toBe(-1) + }) + + test('I32: should read signed 32-bit value', () => { + provider.setDataType(MemoryDataType.I32) + + // 0x7FFFFFFF = 2147483647 (positive max) + expect((provider as any).readValue([0xFF, 0xFF, 0xFF, 0x7F], 0, MemoryDataType.I32, Endianness.Little)) + .toBe(2147483647) + + // 0x80000000 = -2147483648 (negative) + expect((provider as any).readValue([0x00, 0x00, 0x00, 0x80], 0, MemoryDataType.I32, Endianness.Little)) + .toBe(-2147483648) + + // 0xFFFFFFFF = -1 + expect((provider as any).readValue([0xFF, 0xFF, 0xFF, 0xFF], 0, MemoryDataType.I32, Endianness.Little)) + .toBe(-1) + }) + + test('I64: should read signed 64-bit value', () => { + provider.setDataType(MemoryDataType.I64) + + // 0x7FFFFFFFFFFFFFFF = max positive + const maxBytes = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x7F] + const maxValue = (provider as any).readValue(maxBytes, 0, MemoryDataType.I64, Endianness.Little) + expect(maxValue).toBe(BigInt('9223372036854775807')) + + // 0xFFFFFFFFFFFFFFFF = -1 + const negBytes = [0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF] + const negValue = (provider as any).readValue(negBytes, 0, MemoryDataType.I64, Endianness.Little) + expect(negValue).toBe(BigInt(-1)) + }) + }) + + describe('Floating Point Types', () => { + test('Float: should read 32-bit float little endian', () => { + provider.setDataType(MemoryDataType.Float) + + // IEEE 754: 0x3F800000 = 1.0 + const value1 = (provider as any).readValue([0x00, 0x00, 0x80, 0x3F], 0, MemoryDataType.Float, Endianness.Little) + expect(value1).toBeCloseTo(1.0, 6) + + // IEEE 754: 0x40400000 = 3.0 + const value2 = (provider as any).readValue([0x00, 0x00, 0x40, 0x40], 0, MemoryDataType.Float, Endianness.Little) + expect(value2).toBeCloseTo(3.0, 6) + + // IEEE 754: 0x00000000 = 0.0 + const value3 = (provider as any).readValue([0x00, 0x00, 0x00, 0x00], 0, MemoryDataType.Float, Endianness.Little) + expect(value3).toBeCloseTo(0.0, 6) + }) + + test('Float: should read 32-bit float big endian', () => { + provider.setDataType(MemoryDataType.Float) + + // IEEE 754: 0x3F800000 = 1.0 in BE + const value = (provider as any).readValue([0x3F, 0x80, 0x00, 0x00], 0, MemoryDataType.Float, Endianness.Big) + expect(value).toBeCloseTo(1.0, 6) + }) + + test('Double: should read 64-bit double little endian', () => { + provider.setDataType(MemoryDataType.Double) + + // IEEE 754: 0x3FF0000000000000 = 1.0 + const bytes = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xF0, 0x3F] + const value = (provider as any).readValue(bytes, 0, MemoryDataType.Double, Endianness.Little) + expect(value).toBeCloseTo(1.0, 10) + + // IEEE 754: 0x4008000000000000 = 3.0 + const bytes3 = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x40] + const value3 = (provider as any).readValue(bytes3, 0, MemoryDataType.Double, Endianness.Little) + expect(value3).toBeCloseTo(3.0, 10) + }) + + test('Double: should read 64-bit double big endian', () => { + provider.setDataType(MemoryDataType.Double) + + // IEEE 754: 0x3FF0000000000000 = 1.0 in BE + const bytes = [0x3F, 0xF0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00] + const value = (provider as any).readValue(bytes, 0, MemoryDataType.Double, Endianness.Big) + expect(value).toBeCloseTo(1.0, 10) + }) + }) + + describe('Type Size Helper', () => { + test('should return correct type sizes', () => { + expect((provider as any).getTypeSize(MemoryDataType.U8)).toBe(1) + expect((provider as any).getTypeSize(MemoryDataType.I8)).toBe(1) + expect((provider as any).getTypeSize(MemoryDataType.U16)).toBe(2) + expect((provider as any).getTypeSize(MemoryDataType.I16)).toBe(2) + expect((provider as any).getTypeSize(MemoryDataType.U32)).toBe(4) + expect((provider as any).getTypeSize(MemoryDataType.I32)).toBe(4) + expect((provider as any).getTypeSize(MemoryDataType.Float)).toBe(4) + expect((provider as any).getTypeSize(MemoryDataType.U64)).toBe(8) + expect((provider as any).getTypeSize(MemoryDataType.I64)).toBe(8) + expect((provider as any).getTypeSize(MemoryDataType.Double)).toBe(8) + }) + }) +}) diff --git a/__tests__/frontend/memory-write.test.ts b/__tests__/frontend/memory-write.test.ts new file mode 100644 index 0000000..3faf686 --- /dev/null +++ b/__tests__/frontend/memory-write.test.ts @@ -0,0 +1,273 @@ +/** + * Tests for memory write operations. + */ + +import { MemoryContentProvider } from '../../src/frontend/memory_content_provider' + +// Mock vscode with controllable debug session +const mockCustomRequest = jest.fn() +let hasActiveDebugSession = false + +jest.mock('vscode', () => ({ + window: { + createTextEditorDecorationType: jest.fn().mockReturnValue({ + dispose: jest.fn() + }), + showErrorMessage: jest.fn().mockResolvedValue(undefined), + showInformationMessage: jest.fn().mockResolvedValue(undefined) + }, + debug: { + get activeDebugSession() { + return hasActiveDebugSession ? { + customRequest: mockCustomRequest + } : undefined + } + }, + EventEmitter: jest.fn().mockImplementation(() => ({ + event: jest.fn(), + fire: jest.fn() + })), + Position: jest.fn().mockImplementation((line: number, character: number) => ({ + line, + character + })), + Range: jest.fn(), + OverviewRulerLane: { + Right: 4 + }, + Uri: { + parse: jest.fn() + }, + workspace: { + textDocuments: [] + } +})) + +describe('Memory Write Operations', () => { + let provider: MemoryContentProvider + + beforeEach(() => { + provider = new MemoryContentProvider() + hasActiveDebugSession = true + mockCustomRequest.mockClear() + }) + + afterEach(() => { + hasActiveDebugSession = false + }) + + describe('writeByte', () => { + test('should write single byte successfully', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + const success = await provider.writeByte(0x20000000, 0xAB) + + expect(success).toBe(true) + expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', { + address: 0x20000000, + data: 'ab' + }) + }) + + test('should write byte 0x00', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + const success = await provider.writeByte(0x20000000, 0x00) + + expect(success).toBe(true) + expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', { + address: 0x20000000, + data: '00' + }) + }) + + test('should write byte 0xFF', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + const success = await provider.writeByte(0x20000000, 0xFF) + + expect(success).toBe(true) + expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', { + address: 0x20000000, + data: 'ff' + }) + }) + + test('should reject value > 0xFF with an error', async () => { + const success = await provider.writeByte(0x20000000, 0x1AB) + + expect(success).toBe(false) + expect(mockCustomRequest).not.toHaveBeenCalled() + }) + + test('should reject negative values', async () => { + const success = await provider.writeByte(0x20000000, -1) + + expect(success).toBe(false) + expect(mockCustomRequest).not.toHaveBeenCalled() + }) + + test('should reject fractional values', async () => { + const successA = await provider.writeByte(0x20000000, 1.5) + expect(successA).toBe(false) + expect(mockCustomRequest).not.toHaveBeenCalled() + + const successB = await provider.writeByte(0x20000000, 0.5) + expect(successB).toBe(false) + expect(mockCustomRequest).not.toHaveBeenCalled() + }) + + test('should return false when no debug session', async () => { + hasActiveDebugSession = false + + const success = await provider.writeByte(0x20000000, 0xAB) + + expect(success).toBe(false) + expect(mockCustomRequest).not.toHaveBeenCalled() + }) + + test('should return false on write error', async () => { + mockCustomRequest.mockRejectedValue(new Error('Memory access denied')) + + const success = await provider.writeByte(0x20000000, 0xAB) + + expect(success).toBe(false) + }) + + test('should write to different addresses', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + // Test various addresses + const addresses = [ + 0x00000000, + 0x20000000, + 0x40000000, + 0x08000000, + 0xFFFFFFFF + ] + + for (const address of addresses) { + mockCustomRequest.mockClear() + const success = await provider.writeByte(address, 0x42) + expect(success).toBe(true) + expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', { + address, + data: '42' + }) + } + }) + }) + + describe('writeBytes', () => { + test('should write multiple bytes successfully', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + const success = await provider.writeBytes(0x20000000, [0x12, 0x34, 0x56, 0x78]) + + expect(success).toBe(true) + expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', { + address: 0x20000000, + data: '12345678' + }) + }) + + test('should write single byte array', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + const success = await provider.writeBytes(0x20000000, [0xAB]) + + expect(success).toBe(true) + expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', { + address: 0x20000000, + data: 'ab' + }) + }) + + test('should write empty array', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + const success = await provider.writeBytes(0x20000000, []) + + expect(success).toBe(true) + expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', { + address: 0x20000000, + data: '' + }) + }) + + test('should reject array containing values > 0xFF with an error', async () => { + const success = await provider.writeBytes(0x20000000, [0x1FF, 0x2AB, 0x3CD]) + + expect(success).toBe(false) + expect(mockCustomRequest).not.toHaveBeenCalled() + }) + + test('should return false when no debug session', async () => { + hasActiveDebugSession = false + + const success = await provider.writeBytes(0x20000000, [0x12, 0x34]) + + expect(success).toBe(false) + expect(mockCustomRequest).not.toHaveBeenCalled() + }) + + test('should return false on write error', async () => { + mockCustomRequest.mockRejectedValue(new Error('Target not halted')) + + const success = await provider.writeBytes(0x20000000, [0x12, 0x34]) + + expect(success).toBe(false) + }) + + test('should write large byte arrays', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + const largeArray = new Array(256).fill(0).map((_, i) => i) + const success = await provider.writeBytes(0x20000000, largeArray) + + expect(success).toBe(true) + expect(mockCustomRequest).toHaveBeenCalledWith('write-memory', { + address: 0x20000000, + data: largeArray.map(v => v.toString(16).padStart(2, '0')).join('') + }) + }) + }) + + describe('Hex Formatting', () => { + test('should format bytes as lowercase hex', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + await provider.writeByte(0x20000000, 0xAB) + + const callArgs = mockCustomRequest.mock.calls[0] + expect(callArgs[1].data).toBe('ab') + }) + + test('should pad single hex digit with zero', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + await provider.writeByte(0x20000000, 0x5) + + const callArgs = mockCustomRequest.mock.calls[0] + expect(callArgs[1].data).toBe('05') + }) + + test('should handle 0x00 correctly', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + await provider.writeByte(0x20000000, 0x00) + + const callArgs = mockCustomRequest.mock.calls[0] + expect(callArgs[1].data).toBe('00') + }) + + test('should handle 0xFF correctly', async () => { + mockCustomRequest.mockResolvedValue({ success: true }) + + await provider.writeByte(0x20000000, 0xFF) + + const callArgs = mockCustomRequest.mock.calls[0] + expect(callArgs[1].data).toBe('ff') + }) + }) +}) diff --git a/__tests__/frontend/peripheral-search.test.ts b/__tests__/frontend/peripheral-search.test.ts new file mode 100644 index 0000000..94bf826 --- /dev/null +++ b/__tests__/frontend/peripheral-search.test.ts @@ -0,0 +1,114 @@ +/** + * Tests for the peripheral search/filter QuickPick (Phase 3 SVD Enhancements). + * + * Verifies that PeripheralTreeProvider.search builds QuickPick items from the + * loaded peripherals, expands the chosen entry, and refreshes the tree view. + */ + +jest.mock('vscode', () => ({ + ...jest.requireActual('../../__mocks__/vscode'), +})) + +import * as vscode from 'vscode' +import { + PeripheralTreeProvider, + PeripheralNode, +} from '../../src/frontend/peripheral' + +function makePeripheral(name: string, baseAddress: number, description = ''): PeripheralNode { + return new PeripheralNode({ + name, + baseAddress, + description, + totalLength: 0x100, + size: 32, + resetValue: 0n, + }) +} + +describe('Peripheral Search/Filter', () => { + let provider: PeripheralTreeProvider + + beforeEach(() => { + provider = new PeripheralTreeProvider() + // Inject peripherals via the parsing pipeline test seam. + ;(provider as any).peripherials = [ + makePeripheral('TIMER1', 0x40000000, 'General-purpose timer'), + makePeripheral('GPIO_A', 0x50000000, 'GPIO Port A'), + makePeripheral('UART2', 0x60000000, 'Universal Async RX/TX'), + ] + ;(vscode.window.showQuickPick as jest.Mock).mockReset() + ;(vscode.window.showInformationMessage as jest.Mock).mockReset() + }) + + test('passes peripheral metadata as QuickPick items', async () => { + ;(vscode.window.showQuickPick as jest.Mock).mockResolvedValue(undefined) + + await provider.search() + + const call = (vscode.window.showQuickPick as jest.Mock).mock.calls[0] + const items = call[0] as Array<{ label: string; description: string; detail: string }> + expect(items.map((i) => i.label)).toEqual(['TIMER1', 'GPIO_A', 'UART2']) + expect(items[0].description).toMatch(/0x40000000/i) + expect(items[1].detail).toBe('GPIO Port A') + + const options = call[1] + expect(options).toMatchObject({ + matchOnDescription: true, + matchOnDetail: true, + }) + }) + + test('expands the chosen peripheral and refreshes the tree', async () => { + ;(vscode.window.showQuickPick as jest.Mock).mockResolvedValue({ + label: 'GPIO_A', + description: '0x50000000', + detail: 'GPIO Port A', + }) + + const refreshSpy = jest.spyOn(provider as any, 'refresh') + await provider.search() + + const target = (provider as any).peripherials.find( + (p: PeripheralNode) => p.name === 'GPIO_A' + ) + expect(target.expanded).toBe(true) + expect(refreshSpy).toHaveBeenCalled() + }) + + test('handles user cancellation without expanding', async () => { + ;(vscode.window.showQuickPick as jest.Mock).mockResolvedValue(undefined) + + await provider.search() + + for (const p of (provider as any).peripherials as PeripheralNode[]) { + expect(p.expanded).toBe(false) + } + }) + + test('shows an info message when no peripherals are loaded', async () => { + ;(provider as any).peripherials = [] + await provider.search() + expect(vscode.window.showInformationMessage).toHaveBeenCalledWith( + expect.stringContaining('No peripherals') + ) + expect(vscode.window.showQuickPick).not.toHaveBeenCalled() + }) + + test('reveals the selected peripheral when a TreeView is attached', async () => { + const reveal = jest.fn().mockResolvedValue(undefined) + provider.setTreeView({ reveal } as any) + + ;(vscode.window.showQuickPick as jest.Mock).mockResolvedValue({ + label: 'UART2', + description: '0x60000000', + detail: 'Universal Async RX/TX', + }) + + await provider.search() + + expect(reveal).toHaveBeenCalledTimes(1) + const [, options] = reveal.mock.calls[0] + expect(options).toEqual({ select: true, focus: true, expand: true }) + }) +}) diff --git a/__tests__/frontend/register-change-tracking.test.ts b/__tests__/frontend/register-change-tracking.test.ts new file mode 100644 index 0000000..dbd8b10 --- /dev/null +++ b/__tests__/frontend/register-change-tracking.test.ts @@ -0,0 +1,95 @@ +/** + * Tests for register value change highlighting (Phase 3 SVD Enhancements). + * + * Verifies that RegisterNode tracks previousValue/valueChanged across reads + * and exposes a tree icon when the register differs from its reset value. + */ + +jest.mock('vscode', () => ({ + ...jest.requireActual('../../__mocks__/vscode'), +})) + +import { + PeripheralNode, + RegisterNode, + AccessType, +} from '../../src/frontend/peripheral' + +function makeRegister(reset: bigint = 0n): { + peripheral: PeripheralNode + register: RegisterNode +} { + const peripheral = new PeripheralNode({ + name: 'P', + baseAddress: 0, + description: '', + totalLength: 16, + size: 32, + resetValue: 0n, + }) + const register = new RegisterNode(peripheral, { + name: 'R', + addressOffset: 0, + size: 32, + resetValue: reset, + accessType: AccessType.ReadWrite, + }) + return { peripheral, register } +} + +function setBytes(peripheral: PeripheralNode, bytes: number[]): void { + peripheral.currentValue = bytes +} + +describe('Register Change Tracking', () => { + test('initialises with currentValue and previousValue equal to resetValue', () => { + const { register } = makeRegister(0xDEADBEEFn) + expect(register.currentValue).toBe(0xDEADBEEFn) + expect(register.previousValue).toBe(0xDEADBEEFn) + expect(register.valueChanged).toBe(false) + }) + + test('flags valueChanged when the read produces a new value', async () => { + const { peripheral, register } = makeRegister(0n) + + setBytes(peripheral, [0x78, 0x56, 0x34, 0x12]) + await register.update() + + expect(register.currentValue).toBe(0x12345678n) + expect(register.previousValue).toBe(0n) + expect(register.valueChanged).toBe(true) + }) + + test('clears valueChanged when consecutive reads yield the same value', async () => { + const { peripheral, register } = makeRegister(0n) + + setBytes(peripheral, [0x01, 0x00, 0x00, 0x00]) + await register.update() + expect(register.valueChanged).toBe(true) + + // Second read produces the same value — change flag must clear. + await register.update() + expect(register.currentValue).toBe(1n) + expect(register.previousValue).toBe(1n) + expect(register.valueChanged).toBe(false) + }) + + test('tree node carries an icon when current value differs from reset', () => { + const { peripheral, register } = makeRegister(0n) + register.currentValue = 0x42n + + const node = register.getTreeNode() + expect(node.iconPath).toBeDefined() + expect((node.iconPath as any).id).toBe('circle-filled') + expect(typeof node.tooltip).toBe('string') + expect(node.tooltip as string).toMatch(/Reset:/) + expect(node.tooltip as string).toMatch(/Current:/) + }) + + test('tree node has no change icon when value matches reset value', () => { + const { register } = makeRegister(0xCAFEn) + // currentValue still equals resetValue from constructor + const node = register.getTreeNode() + expect(node.iconPath).toBeUndefined() + }) +}) diff --git a/__tests__/frontend/svd-derivedfrom.test.ts b/__tests__/frontend/svd-derivedfrom.test.ts new file mode 100644 index 0000000..ef372b1 --- /dev/null +++ b/__tests__/frontend/svd-derivedfrom.test.ts @@ -0,0 +1,228 @@ +/** + * Tests for SVD inheritance (Phase 3 SVD Enhancements). + * + * Verifies that PeripheralTreeProvider correctly resolves derivedFrom + * references on peripherals, registers, clusters, and fields, including + * transitive chains and circular-reference protection. + */ + +jest.mock('vscode', () => ({ + ...jest.requireActual('../../__mocks__/vscode'), +})) + +import { PeripheralTreeProvider } from '../../src/frontend/peripheral' + +describe('SVD derivedFrom', () => { + let provider: PeripheralTreeProvider + + beforeEach(() => { + provider = new PeripheralTreeProvider() + }) + + test('peripheral inherits registers and properties from base', () => { + const map: Record = { + UART1: { + name: 'UART1', + baseAddress: 0x40000000, + size: 32, + registers: { + register: [ + { name: 'CR', addressOffset: 0x0, size: 32 }, + ], + }, + }, + UART2: { + name: 'UART2', + baseAddress: 0x40001000, + '@_derivedFrom': 'UART1', + }, + } + + ;(provider as any)._resolvePeripheralDerivedFrom(map) + + expect(map.UART2.registers.register[0].name).toBe('CR') + // Derived peripheral keeps its own baseAddress + expect(map.UART2.baseAddress).toBe(0x40001000) + // Marker is removed after resolution + expect(map.UART2['@_derivedFrom']).toBeUndefined() + }) + + test('derived peripheral with extra register keeps all base registers', () => { + const map: Record = { + UART1: { + name: 'UART1', + baseAddress: 0x40000000, + registers: { + register: [ + { name: 'CR', addressOffset: 0x0, size: 32 }, + { name: 'DR', addressOffset: 0x4, size: 32 }, + ], + }, + }, + UART2: { + name: 'UART2', + baseAddress: 0x40001000, + '@_derivedFrom': 'UART1', + // Derived peripheral overrides one register and adds a new one + registers: { + register: [ + { name: 'CR', addressOffset: 0x0, size: 32, access: 'read-write' }, + { name: 'SR', addressOffset: 0x8, size: 32 }, + ], + }, + }, + } + + ;(provider as any)._resolvePeripheralDerivedFrom(map) + + const names = map.UART2.registers.register.map((r: any) => r.name) + // Base register DR must be preserved + expect(names).toContain('DR') + // Overridden register CR must be present with derived properties + expect(names).toContain('CR') + const cr = map.UART2.registers.register.find((r: any) => r.name === 'CR') + expect(cr.access).toBe('read-write') + // New register from derived + expect(names).toContain('SR') + }) + + test('resolves transitive derivedFrom chains', () => { + const map: Record = { + A: { name: 'A', baseAddress: 0x100, value: 'fromA' }, + B: { name: 'B', baseAddress: 0x200, '@_derivedFrom': 'A' }, + C: { name: 'C', baseAddress: 0x300, '@_derivedFrom': 'B' }, + } + + ;(provider as any)._resolvePeripheralDerivedFrom(map) + + expect(map.C.value).toBe('fromA') + expect(map.C.baseAddress).toBe(0x300) + }) + + test('throws on circular peripheral derivedFrom references', () => { + const map: Record = { + A: { name: 'A', '@_derivedFrom': 'B' }, + B: { name: 'B', '@_derivedFrom': 'A' }, + } + + expect(() => + (provider as any)._resolvePeripheralDerivedFrom(map) + ).toThrow(/Circular derivedFrom/) + }) + + test('register-level derivedFrom inherits fields from base register', () => { + const periph = { + name: 'PERIPH', + registers: { + register: [ + { + name: 'BASE', + addressOffset: 0x0, + fields: { + field: [ + { name: 'EN', bitOffset: 0, bitWidth: 1 }, + ], + }, + }, + { + name: 'DERIVED', + addressOffset: 0x4, + '@_derivedFrom': 'BASE', + }, + ], + }, + } + + ;(provider as any)._resolveInnerDerivedFrom(periph) + + const derived = periph.registers.register.find( + (r: any) => r.name === 'DERIVED' + ) + expect(derived.fields.field[0].name).toBe('EN') + expect(derived.addressOffset).toBe(0x4) + expect(derived['@_derivedFrom']).toBeUndefined() + }) + + test('cluster-level derivedFrom inherits registers from base cluster', () => { + const periph = { + name: 'PERIPH', + registers: { + cluster: [ + { + name: 'CHAN_BASE', + addressOffset: 0x0, + register: [ + { name: 'CFG', addressOffset: 0x0 }, + ], + }, + { + name: 'CHAN1', + addressOffset: 0x10, + '@_derivedFrom': 'CHAN_BASE', + }, + ], + }, + } + + ;(provider as any)._resolveInnerDerivedFrom(periph) + + const derived = periph.registers.cluster.find( + (c: any) => c.name === 'CHAN1' + ) + expect(derived.register[0].name).toBe('CFG') + expect(derived.addressOffset).toBe(0x10) + }) + + test('field-level derivedFrom inherits properties from base field', () => { + const periph = { + name: 'PERIPH', + registers: { + register: [ + { + name: 'CTRL', + addressOffset: 0x0, + fields: { + field: [ + { + name: 'EN', + bitOffset: 0, + bitWidth: 1, + description: 'Enable', + }, + { + name: 'EN2', + bitOffset: 1, + '@_derivedFrom': 'EN', + }, + ], + }, + }, + ], + }, + } + + ;(provider as any)._resolveInnerDerivedFrom(periph) + + const en2 = periph.registers.register[0].fields.field.find( + (f: any) => f.name === 'EN2' + ) + expect(en2.bitWidth).toBe(1) + expect(en2.description).toBe('Enable') + expect(en2.bitOffset).toBe(1) + }) + + test('throws on circular register derivedFrom references', () => { + const periph = { + registers: { + register: [ + { name: 'A', addressOffset: 0, '@_derivedFrom': 'B' }, + { name: 'B', addressOffset: 4, '@_derivedFrom': 'A' }, + ], + }, + } + + expect(() => + (provider as any)._resolveInnerDerivedFrom(periph) + ).toThrow(/Circular derivedFrom/) + }) +}) diff --git a/__tests__/frontend/svd-file-discovery.test.ts b/__tests__/frontend/svd-file-discovery.test.ts new file mode 100644 index 0000000..6bc885f --- /dev/null +++ b/__tests__/frontend/svd-file-discovery.test.ts @@ -0,0 +1,117 @@ +/** + * Tests for SVD file discovery (Phase 3 SVD Enhancements). + * + * Verifies that PeripheralTreeProvider.findSVDFile searches the configured + * locations in the expected order and prefers files matching a device name. + */ + +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' + +jest.mock('vscode', () => ({ + ...jest.requireActual('../../__mocks__/vscode'), +})) + +let mockHome: string | undefined +jest.mock('os', () => { + const actual = jest.requireActual('os') + return { + ...actual, + homedir: () => mockHome ?? actual.homedir(), + } +}) + +import * as vscode from 'vscode' +import { PeripheralTreeProvider } from '../../src/frontend/peripheral' + +describe('SVD File Discovery', () => { + let tmpRoot: string + let workspaceDir: string + let fakeHome: string + + beforeEach(() => { + tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'svd-discovery-')) + workspaceDir = path.join(tmpRoot, 'workspace') + fs.mkdirSync(workspaceDir, { recursive: true }) + + // Redirect homedir so PlatformIO discovery does not touch a real install + fakeHome = path.join(tmpRoot, 'fakehome') + fs.mkdirSync(fakeHome, { recursive: true }) + mockHome = fakeHome + + ;(vscode.workspace as any).workspaceFolders = [ + { uri: { fsPath: workspaceDir }, name: 'ws', index: 0 }, + ] + }) + + afterEach(() => { + mockHome = undefined + ;(vscode.workspace as any).workspaceFolders = undefined + fs.rmSync(tmpRoot, { recursive: true, force: true }) + }) + + test('returns undefined when no SVD files exist', () => { + const provider = new PeripheralTreeProvider() + expect(provider.findSVDFile()).toBeUndefined() + }) + + test('discovers .svd file at the workspace root', () => { + const svd = path.join(workspaceDir, 'device.svd') + fs.writeFileSync(svd, '') + const provider = new PeripheralTreeProvider() + expect(provider.findSVDFile()).toBe(svd) + }) + + test('prefers .vscode/*.svd over workspace root', () => { + const vscodeDir = path.join(workspaceDir, '.vscode') + fs.mkdirSync(vscodeDir) + const dotSvd = path.join(vscodeDir, 'a.svd') + const rootSvd = path.join(workspaceDir, 'b.svd') + fs.writeFileSync(dotSvd, '') + fs.writeFileSync(rootSvd, '') + const provider = new PeripheralTreeProvider() + expect(provider.findSVDFile()).toBe(dotSvd) + }) + + test('discovers SVDs under ~/.platformio/packages//svd', () => { + const pkgSvdDir = path.join( + fakeHome, + '.platformio', + 'packages', + 'framework-arduinoespressif32', + 'svd' + ) + fs.mkdirSync(pkgSvdDir, { recursive: true }) + const svd = path.join(pkgSvdDir, 'esp32.svd') + fs.writeFileSync(svd, '') + const provider = new PeripheralTreeProvider() + expect(provider.findSVDFile()).toBe(svd) + }) + + test('prefers a candidate matching the device name', () => { + fs.writeFileSync(path.join(workspaceDir, 'random.svd'), '') + const target = path.join(workspaceDir, 'esp32.svd') + fs.writeFileSync(target, '') + const provider = new PeripheralTreeProvider() + expect(provider.findSVDFile('esp32')).toBe(target) + }) + + test('returns undefined when device name is given but no filename matches', () => { + const a = path.join(workspaceDir, 'a.svd') + const b = path.join(workspaceDir, 'b.svd') + fs.writeFileSync(a, '') + fs.writeFileSync(b, '') + const provider = new PeripheralTreeProvider() + // A device name is provided but neither 'a.svd' nor 'b.svd' contains it, + // so findSVDFile must return undefined rather than silently loading the wrong file. + expect(provider.findSVDFile('does-not-exist')).toBeUndefined() + }) + + test('ignores files without .svd extension', () => { + fs.writeFileSync(path.join(workspaceDir, 'notes.txt'), 'hello') + fs.writeFileSync(path.join(workspaceDir, 'config.json'), '{}') + const provider = new PeripheralTreeProvider() + expect(provider.findSVDFile()).toBeUndefined() + }) +}) diff --git a/__tests__/integration/feature-integration.test.ts b/__tests__/integration/feature-integration.test.ts new file mode 100644 index 0000000..ed631c4 --- /dev/null +++ b/__tests__/integration/feature-integration.test.ts @@ -0,0 +1,497 @@ +/** + * Integration tests for v1.2.0 features. + * + * Each suite wires multiple modules together to verify that cross-component + * workflows produce the expected end-to-end behaviour without a real VS Code + * host or hardware target. + */ + +// ─── Shared VS Code mock ───────────────────────────────────────────────────── + +const mockCustomRequest = jest.fn() +let mockActiveDebugSession: any = undefined + +jest.mock('vscode', () => { + const actual = jest.requireActual('../../__mocks__/vscode') + + const mockOutputChannel = { + appendLine: jest.fn(), + clear: jest.fn(), + show: jest.fn(), + } + + const mockThenable = { + then: jest.fn(function (this: any, cb?: (v: any) => any) { + if (cb) { cb(undefined) } + return this + }), + catch: jest.fn().mockReturnThis(), + } + + return { + ...actual, + window: { + ...actual.window, + createOutputChannel: jest.fn().mockReturnValue(mockOutputChannel), + showErrorMessage: jest.fn().mockReturnValue(mockThenable), + showWarningMessage: jest.fn().mockReturnValue(mockThenable), + showInformationMessage: jest.fn().mockReturnValue(mockThenable), + showOpenDialog: jest.fn().mockResolvedValue(undefined), + showQuickPick: jest.fn().mockResolvedValue(undefined), + createTextEditorDecorationType: jest.fn().mockReturnValue({ dispose: jest.fn() }), + }, + debug: { + get activeDebugSession() { return mockActiveDebugSession }, + }, + workspace: { textDocuments: [] }, + env: { + clipboard: { writeText: jest.fn().mockResolvedValue(undefined) }, + openExternal: jest.fn().mockResolvedValue(true), + }, + commands: { executeCommand: jest.fn().mockResolvedValue(undefined) }, + } +}) + +import * as vscode from 'vscode' + +// ─── 1. Memory editor: write → refresh → diff detection ────────────────────── + +import { MemoryContentProvider, MemoryDataType, Endianness } from '../../src/frontend/memory_content_provider' + +describe('Memory editor – write / refresh / diff integration', () => { + let provider: MemoryContentProvider + + beforeEach(() => { + jest.clearAllMocks() + provider = new MemoryContentProvider() + mockActiveDebugSession = { customRequest: mockCustomRequest } + }) + + afterEach(() => { + mockActiveDebugSession = undefined + }) + + test('first read stores snapshot; second read with changed byte reports diff', async () => { + const firstBytes = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF] + const secondBytes = [0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, + 0x88, 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0x01] // byte 15 changed + + const uri = { query: 'address=0x20000000&length=0x10' } as any + + // First read – establishes snapshot + mockCustomRequest.mockResolvedValueOnce({ bytes: firstBytes }) + await provider.provideTextDocumentContent(uri) + expect(provider.getChangedOffsets()).toHaveLength(0) + + // Second read – one byte changed + mockCustomRequest.mockResolvedValueOnce({ bytes: secondBytes }) + const output = await provider.provideTextDocumentContent(uri) + + const changed = provider.getChangedOffsets() + expect(changed).toHaveLength(1) // previousBytes now holds the prior snapshot, so the diff is visible + expect(changed).toContain(15) + expect(output).toContain('Diff: 1 byte(s) changed since last read') + }) + + test('no diff line when bytes are identical across two reads', async () => { + const bytes = [0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0x01, 0x02, 0x03, + 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B] + const uri = { query: 'address=0x10000000&length=0x10' } as any + + mockCustomRequest.mockResolvedValueOnce({ bytes }) + await provider.provideTextDocumentContent(uri) + + mockCustomRequest.mockResolvedValueOnce({ bytes: [...bytes] }) + const output = await provider.provideTextDocumentContent(uri) + + expect(output).not.toContain('Diff:') + }) + + test('ASCII column present when showAscii=true; absent when toggled off', async () => { + const bytes = Array.from({ length: 16 }, (_, i) => 0x41 + i) // 'A'...'P' + const uri = { query: 'address=0x20000000&length=0x10' } as any + + mockCustomRequest.mockResolvedValueOnce({ bytes }) + const withAscii = await provider.provideTextDocumentContent(uri) + expect(withAscii).toContain('ASCII') + expect(withAscii).toContain('|') + + provider.toggleAsciiView() + mockCustomRequest.mockResolvedValueOnce({ bytes }) + const withoutAscii = await provider.provideTextDocumentContent(uri) + expect(withoutAscii).not.toContain('ASCII') + expect(withoutAscii).not.toContain('|') + }) + + test('data type header changes after setDataType', async () => { + const bytes = Array.from({ length: 16 }, (_, i) => i) + const uri = { query: 'address=0x20000000&length=0x10' } as any + + mockCustomRequest.mockResolvedValueOnce({ bytes }) + const u8Output = await provider.provideTextDocumentContent(uri) + expect(u8Output).toContain(`Data Type: ${MemoryDataType.U8}`) + + provider.setDataType(MemoryDataType.U32) + mockCustomRequest.mockResolvedValueOnce({ bytes }) + const u32Output = await provider.provideTextDocumentContent(uri) + expect(u32Output).toContain(`Data Type: ${MemoryDataType.U32}`) + }) + + test('endianness toggle is reflected in successive content renders', async () => { + const bytes = [0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, + 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10] + const uri = { query: 'address=0x20000000&length=0x10' } as any + + provider.setDataType(MemoryDataType.U32) + + mockCustomRequest.mockResolvedValueOnce({ bytes }) + const leOutput = await provider.provideTextDocumentContent(uri) + expect(leOutput).toContain(`Endianness: ${Endianness.Little}`) + + provider.toggleEndianness() + mockCustomRequest.mockResolvedValueOnce({ bytes }) + const beOutput = await provider.provideTextDocumentContent(uri) + expect(beOutput).toContain(`Endianness: ${Endianness.Big}`) + // The U32 interpreted values should differ between LE and BE + const leSection = leOutput.split('Data Type Interpretation')[1] ?? '' + const beSection = beOutput.split('Data Type Interpretation')[1] ?? '' + expect(leSection).not.toBe(beSection) + }) +}) + +// ─── 2. RTOS: detection → parsing → adapter thread enrichment ──────────────── + +import { RTOSManager, RTOSType } from '../../src/backend/rtos' +import { GDBDebugSession } from '../../src/backend/adapter' + +function makeReader(values: Record) { + return { + evalExpression: jest.fn().mockImplementation((expr: string) => { + if (!(expr in values) || values[expr] === undefined) { + return Promise.reject(new Error(`unknown: ${expr}`)) + } + return Promise.resolve({ + result: (path: string) => (path === 'value' ? values[expr] : undefined), + }) + }), + } +} + +describe('RTOS – detect → parse → DAP thread list integration', () => { + test('RTOSManager auto-detects FreeRTOS and returns parsed thread', async () => { + const manager = new RTOSManager() + const reader = makeReader({ + '&pxCurrentTCB': '0x20000100', + pxCurrentTCB: '0x20000100', + '((TCB_t *)pxCurrentTCB)->pcTaskName': '"main_task"', + '((TCB_t *)pxCurrentTCB)->uxPriority': '5', + '((TCB_t *)pxCurrentTCB)->eCurrentState': '0', + '((TCB_t *)pxCurrentTCB)->pxTopOfStack': '0x20001800', + '((TCB_t *)pxCurrentTCB)->pxStack': '0x20001000', + '((TCB_t *)pxCurrentTCB)->pxEndOfStack': '0x20002000', + // list-walking: uxCurrentNumberOfTasks = 1, so no extra traversal + uxCurrentNumberOfTasks: '1', + }) + + const result = await manager.load(reader as any, { + enabled: true, + requestedType: 'auto', + currentGdbThreadId: 3, + }) + + expect(result.type).toBe(RTOSType.FreeRTOS) + expect(result.threads.length).toBeGreaterThanOrEqual(1) + const current = result.threads.find((t) => t.isCurrent) + expect(current).toBeDefined() + expect(current!.name).toBe('main_task') + expect(current!.priority).toBe(5) + expect(current!.state).toBe('running') + }) + + test('RTOSManager returns empty threads when RTOS disabled', async () => { + const manager = new RTOSManager() + const reader = makeReader({}) + const result = await manager.load(reader as any, { enabled: false }) + expect(result.type).toBe(RTOSType.None) + expect(result.threads).toHaveLength(0) + }) + + test('RTOSManager returns None when no RTOS symbols present', async () => { + const manager = new RTOSManager() + const reader = { evalExpression: jest.fn().mockRejectedValue(new Error('no symbols')) } + const result = await manager.load(reader as any, { enabled: true, requestedType: 'auto' }) + expect(result.type).toBe(RTOSType.None) + }) + + test('GDBDebugSession.threadsRequest enriches thread name with RTOS metadata', async () => { + const session = new GDBDebugSession() as any + session.stopped = true + session.currentThreadId = 1 + session.args = { rtos: { enabled: true, type: 'auto' } } + + session.miDebugger = { + sendCommand: jest.fn().mockImplementation((cmd: string) => { + if (cmd === 'thread-list-ids') { + return Promise.resolve({ + result: (p: string) => { + if (p === 'thread-ids') return [['id', '1']] + if (p === 'current-thread-id') return '1' + }, + }) + } + if (cmd === 'thread-info 1') { + return Promise.resolve({ + result: (p: string) => + p === 'threads' + ? [[['id', '1'], ['target-id', 'Thread 1'], ['details', 'idle']]] + : undefined, + }) + } + return Promise.reject(new Error(`unexpected: ${cmd}`)) + }), + } + + session.rtosManager = { + load: jest.fn().mockResolvedValue({ + type: RTOSType.FreeRTOS, + threads: [{ + id: 1, + gdbThreadId: 1, + name: 'IdleTask', + state: 'running', + priority: 0, + isCurrent: true, + source: RTOSType.FreeRTOS, + }], + }), + } + + session.sendResponse = jest.fn() + session.sendErrorResponse = jest.fn() + + const response: any = {} + await session.threadsRequest(response) + + expect(response.body.threads).toHaveLength(1) + const label: string = response.body.threads[0].name + expect(label).toContain('IdleTask') + expect(label).toMatch(/running/i) + }) +}) + +// ─── 3. Diagnostics: log → classify → export → clear ──────────────────────── + +import { + getDiagnosticsManager, + resetDiagnosticsManager, +} from '../../src/frontend/diagnostics' + +describe('Diagnostics pipeline – log / classify / export / clear integration', () => { + beforeEach(() => { + jest.clearAllMocks() + resetDiagnosticsManager() + }) + + test('logged entries appear in exportLog output with correct structure', () => { + const mgr = getDiagnosticsManager() + + mgr.info('TestSource', 'Session started') + mgr.warn('TestSource', 'Low memory', 'heap < 512 bytes') + mgr.error('TestSource', 'Connection refused', 'port 3333 not listening') + + const exported = mgr.exportLog() + + expect(exported).toContain('PlatformIO Debug Diagnostic Log') + expect(exported).toContain('Total Entries: 3') + expect(exported).toContain('[INFO]') + expect(exported).toContain('Session started') + expect(exported).toContain('[WARN]') + expect(exported).toContain('Low memory') + expect(exported).toContain('[ERROR]') + expect(exported).toContain('Connection refused') + expect(exported).toContain('port 3333 not listening') + }) + + test('clearLog removes all entries and subsequent export shows 0 entries', () => { + const mgr = getDiagnosticsManager() + + mgr.error('A', 'first') + mgr.error('B', 'second') + expect(mgr.getLogEntries()).toHaveLength(2) + + mgr.clearLog() + + expect(mgr.getLogEntries()).toHaveLength(0) + const exported = mgr.exportLog() + expect(exported).toContain('Total Entries: 0') + }) + + test('handleConnectionError classifies the error and logs it', () => { + const mgr = getDiagnosticsManager() + mgr.handleConnectionError('Connection refused by remote host') + + const errors = mgr.getLogEntries('error') + expect(errors.length).toBeGreaterThanOrEqual(1) + const messages = errors.map((e) => e.message).join(' ') + expect(messages).toMatch(/connection|refused/i) + }) + + test('handleSVDError classifies the error and logs it', () => { + const mgr = getDiagnosticsManager() + mgr.handleSVDError('No such file or directory: /path/to/device.svd') + + const errors = mgr.getLogEntries('error') + expect(errors.length).toBeGreaterThanOrEqual(1) + }) + + test('getLogEntries filtered by level returns only matching entries', () => { + const mgr = getDiagnosticsManager() + mgr.info('S', 'info message') + mgr.warn('S', 'warn message') + mgr.error('S', 'error message') + + const errors = mgr.getLogEntries('error') + expect(errors).toHaveLength(1) + expect(errors[0].level).toBe('error') + + const infos = mgr.getLogEntries('info') + expect(infos).toHaveLength(1) + expect(infos[0].level).toBe('info') + }) + + test('showInfo delegates to the VS Code info message and logs the entry', () => { + const mgr = getDiagnosticsManager() + mgr.showInfo('Reloaded SVD: /tmp/device.svd') + + const infos = mgr.getLogEntries('info') + expect(infos.length).toBeGreaterThanOrEqual(1) + expect(vscode.window.showInformationMessage).toHaveBeenCalled() + }) +}) + +// ─── 4. SVD peripheral: FieldNode tooltip + RegisterNode change highlight ──── + +import { + AccessType, + FieldNode, + PeripheralNode, + RegisterNode, +} from '../../src/frontend/peripheral' + +function makeRegister( + parent: PeripheralNode, + name: string, + options: Partial<{ + addressOffset: number + resetValue: number + size: number + accessType: AccessType + }> = {} +): RegisterNode { + return new RegisterNode(parent, { + name, + description: `${name} register`, + addressOffset: options.addressOffset ?? 0, + resetValue: options.resetValue ?? 0, + size: options.size ?? 32, + accessType: options.accessType ?? AccessType.ReadWrite, + }) +} + +describe('SVD peripheral – bit-field tooltip + register diff integration', () => { + let peripheral: PeripheralNode + let register: RegisterNode + + beforeEach(() => { + jest.clearAllMocks() + peripheral = new PeripheralNode({ + name: 'GPIO', + baseAddress: 0x48000000, + description: 'GPIO controller', + totalLength: 0x400, + size: 32, + resetValue: 0n, + }) + register = makeRegister(peripheral, 'MODER') + }) + + test('FieldNode tooltip includes bit range and access type', () => { + new FieldNode(register, { + name: 'MODE0', + description: 'Port 0 mode', + offset: 0, + width: 2, + accessType: AccessType.ReadWrite, + }) + + const field = (register as any).children[0] as FieldNode + register.currentValue = 0n + + const treeNode = field.getTreeNode() + + expect(typeof treeNode.tooltip).toBe('string') + const tooltip = treeNode.tooltip as string + expect(tooltip).toContain('Port 0 mode') + expect(tooltip).toContain('Bits [1:0]') + expect(tooltip).toContain('width: 2') + expect(tooltip).toContain('Access:') + }) + + test('FieldNode tooltip lists all enumeration values sorted numerically', () => { + new FieldNode(register, { + name: 'SPEED', + description: 'Output speed', + offset: 4, + width: 2, + accessType: AccessType.ReadWrite, + enumeration: { + '0': { name: 'Low', value: 0n, description: 'Low speed' }, + '1': { name: 'Medium', value: 1n, description: 'Medium speed' }, + '3': { name: 'High', value: 3n, description: 'High speed' }, + }, + }) + + const field = (register as any).children[0] as FieldNode + register.currentValue = 1n << 4n // SPEED = 1 (Medium) + + const treeNode = field.getTreeNode() + const tooltip = treeNode.tooltip as string + + expect(tooltip).toContain('Values:') + // Sorted: Low=0 before Medium=1 before High=3 + const lowPos = tooltip.indexOf('Low') + const mediumPos = tooltip.indexOf('Medium') + const highPos = tooltip.indexOf('High') + expect(lowPos).toBeLessThan(mediumPos) + expect(mediumPos).toBeLessThan(highPos) + }) + + test('RegisterNode.getTreeNode reflects current ≠ reset value in tooltip', () => { + register.currentValue = 0xDEADBEEFn + + const treeNode = register.getTreeNode() + const tooltip = treeNode.tooltip as string + + expect(tooltip).toContain('Current:') + expect(tooltip).toContain('Reset:') + }) + + test('ReadOnly FieldNode shows field-ro context and no edit option', () => { + const roRegister = makeRegister(peripheral, 'IDR', { accessType: AccessType.ReadOnly }) + + new FieldNode(roRegister, { + name: 'IDR0', + description: 'Input data bit 0', + offset: 0, + width: 1, + accessType: AccessType.ReadOnly, + }) + + const field = (roRegister as any).children[0] as FieldNode + roRegister.currentValue = 1n + + const treeNode = field.getTreeNode() + expect(treeNode.contextValue).toMatch(/field-ro|field/) + }) +}) diff --git a/jest.config.js b/jest.config.js index 67a4bce..371cb35 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,6 +9,10 @@ module.exports = { '!**/*.test.ts', '!**/*.spec.ts', '!**/*.d.ts', + '!__mocks__/**', ], moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + moduleNameMapper: { + '^vscode$': '/__mocks__/vscode.ts', + }, }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index add314c..b637cf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pioarduino-vscode-debug", - "version": "1.1.4", + "version": "1.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pioarduino-vscode-debug", - "version": "1.1.4", + "version": "1.2.0", "dependencies": { "@vscode/debugadapter": "^1.68.0", "@vscode/debugprotocol": "^1.68.0", @@ -536,9 +536,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, @@ -548,9 +548,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -1052,6 +1052,18 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2778,9 +2790,9 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "funding": [ { "type": "github", @@ -2793,9 +2805,9 @@ } }, "node_modules/fast-xml-parser": { - "version": "5.5.11", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.11.tgz", - "integrity": "sha512-QL0eb0YbSTVWF6tTf1+LEMSgtCEjBYPpnAjoLC8SscESlAjXEIRJ7cHtLG0pLeDFaZLa4VKZLArtA/60ZS7vyA==", + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.2.tgz", + "integrity": "sha512-P7oW7tLbYnhOLQk/Gv7cZgzgMPP/XN03K02/Jy6Y/NHzyIAIpxuZIM/YqAkfiXFPxA2CTm7NtCijK9EDu09u2w==", "funding": [ { "type": "github", @@ -2804,8 +2816,9 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.1.4", - "path-expression-matcher": "^1.4.0", + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.5", + "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { diff --git a/package.json b/package.json index a821381..4ec1c03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pioarduino-vscode-debug", - "version": "1.1.4", + "version": "1.2.0", "description": "pioarduino debugger for VSCode with MI3/MI4 support", "main": "dist/extension.js", "engines": { diff --git a/src/backend/adapter.ts b/src/backend/adapter.ts index 8111b8c..0787d33 100644 --- a/src/backend/adapter.ts +++ b/src/backend/adapter.ts @@ -21,6 +21,7 @@ import { VariableObject, MIError } from './mi2/types'; import { expandValue } from './expand_value'; import { MI2 } from './mi2/mi2'; import { MINode } from './mi_parse'; +import { RTOSManager, RTOSThread, RTOSType } from './rtos'; import { SymbolTable } from './symbols'; /** Wraps a variable reference with options. */ @@ -59,6 +60,10 @@ export class GDBDebugSession extends DebugSession { private frameIdMap: Map = new Map(); private nextFrameId: number = 256; private fileExistsCache: Map = new Map(); + private rtosManager = new RTOSManager(); + private rtosType: RTOSType = RTOSType.None; + private rtosThreadMap: Map = new Map(); + private dapThreadIdMap: Map = new Map(); private miDebugger: MI2; private args: any; private quit: boolean; @@ -129,6 +134,9 @@ export class GDBDebugSession extends DebugSession { this.frameIdMap = new Map(); this.nextFrameId = 256; this.fileExistsCache = new Map(); + this.rtosType = RTOSType.None; + this.rtosThreadMap = new Map(); + this.dapThreadIdMap = new Map(); const pioArgs = ['debug']; if (this.args.projectEnvName) { @@ -374,52 +382,70 @@ export class GDBDebugSession extends DebugSession { } /** Reads CPU registers via MI. */ - private customReadRegistersRequest(response: any): void { - this.miDebugger.sendCommand(`data-list-register-values --thread ${this.currentThreadId} x`).then( - (result) => { - if (result.resultRecords.resultClass === 'done') { - const registers = result.resultRecords.results[0][1]; - response.body = registers.map((reg: any) => { - const obj: any = {}; - reg.forEach((pair: any) => { - obj[pair[0]] = pair[1]; - }); - return obj; - }); + private async customReadRegistersRequest(response: any): Promise { + try { + let result: MINode; + try { + result = await this.miDebugger.sendCommand( + `data-list-register-values --thread ${this.currentThreadId} x` + ); + } catch (threadErr) { + if (threadErr.toString().includes('Invalid thread id')) { + result = await this.miDebugger.sendCommand('data-list-register-values x'); } else { - response.body = { error: 'Unable to parse response' }; + throw threadErr; } - this.sendResponse(response); - }, - (err) => { - response.body = { error: err }; - this.sendErrorResponse(response, 115, `Unable to read registers: ${err.toString()}`); } - ); + if (result.resultRecords.resultClass === 'done') { + const registers = result.resultRecords.results[0][1]; + response.body = registers.map((reg: any) => { + const obj: any = {}; + reg.forEach((pair: any) => { + obj[pair[0]] = pair[1]; + }); + return obj; + }); + } else { + response.body = { error: 'Unable to parse response' }; + } + this.sendResponse(response); + } catch (err) { + response.body = { error: err }; + this.sendErrorResponse(response, 115, `Unable to read registers: ${err.toString()}`); + } } /** Reads register names via MI. */ - private customReadRegisterListRequest(response: any): void { - this.miDebugger.sendCommand(`data-list-register-names --thread ${this.currentThreadId}`).then( - (result) => { - if (result.resultRecords.resultClass === 'done') { - let names: string[]; - result.resultRecords.results.forEach((entry: any) => { - if (entry[0] === 'register-names') { - names = entry[1]; - } - }); - response.body = names; + private async customReadRegisterListRequest(response: any): Promise { + try { + let result: MINode; + try { + result = await this.miDebugger.sendCommand( + `data-list-register-names --thread ${this.currentThreadId}` + ); + } catch (threadErr) { + if (threadErr.toString().includes('Invalid thread id')) { + result = await this.miDebugger.sendCommand('data-list-register-names'); } else { - response.body = { error: result.resultRecords.results }; + throw threadErr; } - this.sendResponse(response); - }, - (err) => { - response.body = { error: err }; - this.sendErrorResponse(response, 116, `Unable to read register list: ${err.toString()}`); } - ); + if (result.resultRecords.resultClass === 'done') { + let names: string[]; + result.resultRecords.results.forEach((entry: any) => { + if (entry[0] === 'register-names') { + names = entry[1]; + } + }); + response.body = names; + } else { + response.body = { error: result.resultRecords.results }; + } + this.sendResponse(response); + } catch (err) { + response.body = { error: err }; + this.sendErrorResponse(response, 116, `Unable to read register list: ${err.toString()}`); + } } /** Handles DAP disconnect. */ @@ -569,6 +595,59 @@ export class GDBDebugSession extends DebugSession { } } + private async refreshRTOSThreads(gdbThreadIds: number[]): Promise { + this.dapThreadIdMap = new Map(); + this.rtosThreadMap = new Map(); + gdbThreadIds.forEach((threadId) => this.dapThreadIdMap.set(threadId, threadId)); + + const rtosConfig = this.args?.rtos; + const enabled = rtosConfig?.enabled !== false; + if (!enabled) { + this.rtosType = RTOSType.None; + return; + } + + try { + const result = await this.rtosManager.load(this.miDebugger, { + enabled, + requestedType: rtosConfig?.type || 'auto', + currentGdbThreadId: this.currentThreadId, + gdbThreadIds, + }); + + this.rtosType = result.type; + result.threads.forEach((thread) => { + const gdbThreadId = thread.gdbThreadId ?? thread.id; + this.dapThreadIdMap.set(thread.id, gdbThreadId); + this.rtosThreadMap.set(gdbThreadId, thread); + }); + } catch (err) { + this.rtosType = RTOSType.Unknown; + this.handleMsg('log', `RTOS discovery failed: ${err}`); + } + } + + private formatThreadLabel(baseLabel: string, rtosThread?: RTOSThread): string { + if (!rtosThread) { + return baseLabel; + } + + const label = rtosThread.name || baseLabel; + const details: string[] = [RTOSType[rtosThread.source]]; + if (rtosThread.state && rtosThread.state !== 'unknown') { + details.push(rtosThread.state); + } + if (rtosThread.priority !== undefined) { + details.push(`prio ${rtosThread.priority}`); + } + + return details.length > 0 ? `${label} [${details.join(', ')}]` : label; + } + + private resolveGDBThreadId(threadId: number): number { + return this.dapThreadIdMap.get(threadId) ?? threadId; + } + /** Handles GDB quit. */ private quitEvent(): void { this.quit = true; @@ -763,6 +842,8 @@ export class GDBDebugSession extends DebugSession { this.currentThreadId = threadIds[0]; } + await this.refreshRTOSThreads(threadIds); + const threadInfoResults = await Promise.all( threadIds.map((id) => this.miDebugger.sendCommand(`thread-info ${id}`)) ); @@ -775,7 +856,8 @@ export class GDBDebugSession extends DebugSession { const id = parseInt(MINode.valueOf(thread, 'id'), 10); const targetId = MINode.valueOf(thread, 'target-id'); const details = MINode.valueOf(thread, 'details'); - return new Thread(id, details || targetId); + const label = this.formatThreadLabel(details || targetId, this.rtosThreadMap.get(id)); + return new Thread(id, label); } return null; }) @@ -790,12 +872,13 @@ export class GDBDebugSession extends DebugSession { protected async stackTraceRequest(response: any, args: any): Promise { try { - const stack = await this.miDebugger.getStack(args.threadId, args.startFrame, args.levels); + const gdbThreadId = this.resolveGDBThreadId(args.threadId); + const stack = await this.miDebugger.getStack(gdbThreadId, args.startFrame, args.levels); const frames: StackFrame[] = []; for (const frame of stack) { const frameIndex = this.nextFrameId++; - this.frameIdMap.set(frameIndex, { threadId: args.threadId, frameLevel: parseInt(frame.level, 10) }); + this.frameIdMap.set(frameIndex, { threadId: gdbThreadId, frameLevel: parseInt(frame.level, 10) }); const filePath = frame.file; let useDisassembly = this.forceDisassembly || !filePath; @@ -980,8 +1063,12 @@ export class GDBDebugSession extends DebugSession { try { for (const globalVar of globalVars) { const varName = `var_global_${globalVar.name}`; - const varObj = await this.getVarObjByName(globalVar.name, varName); - variables.push(varObj.toProtocolVariable()); + try { + const varObj = await this.getVarObjByName(globalVar.name, varName); + variables.push(varObj.toProtocolVariable()); + } catch (symErr) { + // Skip symbols GDB cannot create variable objects for (e.g. LTO internals) + } } response.body = { variables }; this.sendResponse(response); @@ -1005,8 +1092,12 @@ export class GDBDebugSession extends DebugSession { for (const staticVar of staticVars) { const varName = `var_static_${fileName}_${staticVar.name}`; - const varObj = await this.getVarObjByName(staticVar.name, varName); - variables.push(varObj.toProtocolVariable()); + try { + const varObj = await this.getVarObjByName(staticVar.name, varName); + variables.push(varObj.toProtocolVariable()); + } catch (symErr) { + // Skip symbols GDB cannot create variable objects for (e.g. LTO internals) + } } response.body = { variables }; this.sendResponse(response); @@ -1379,4 +1470,7 @@ export class GDBDebugSession extends DebugSession { } } -DebugSession.run(GDBDebugSession); +// Only start the debug adapter when this file is executed directly (not when imported by tests). +if (require.main === module) { + DebugSession.run(GDBDebugSession); +} diff --git a/src/backend/mi2/mi2.ts b/src/backend/mi2/mi2.ts index 70b9194..bc8aa31 100644 --- a/src/backend/mi2/mi2.ts +++ b/src/backend/mi2/mi2.ts @@ -150,6 +150,7 @@ export class MI2 extends EventEmitter { this.debugReadyFired = true; this.emit('debug-ready'); }, 200); + this.debugReadyTimeout.unref(); this.once('generic-stopped', () => { if (!this.debugReadyFired) { clearTimeout(this.debugReadyTimeout); diff --git a/src/backend/rtos.ts b/src/backend/rtos.ts new file mode 100644 index 0000000..077938f --- /dev/null +++ b/src/backend/rtos.ts @@ -0,0 +1,658 @@ +import { MI2 } from './mi2/mi2'; + +export enum RTOSType { + None = 'none', + FreeRTOS = 'freertos', + ThreadX = 'threadx', + Zephyr = 'zephyr', + Unknown = 'unknown', +} + +export type RTOSRequestedType = 'auto' | RTOSType; + +export type RTOSThreadState = 'running' | 'ready' | 'blocked' | 'suspended' | 'unknown'; + +export interface RTOSThreadStackInfo { + base: number; + size: number; + used?: number; +} + +export interface RTOSThread { + id: number; + gdbThreadId?: number; + name: string; + state: RTOSThreadState; + priority?: number; + stackPointer?: number; + stackInfo?: RTOSThreadStackInfo; + source: RTOSType; + isCurrent?: boolean; +} + +export interface RTOSLoadOptions { + enabled?: boolean; + requestedType?: RTOSRequestedType | string; + currentGdbThreadId?: number; + gdbThreadIds?: number[]; +} + +export interface RTOSLoadResult { + type: RTOSType; + threads: RTOSThread[]; +} + +export interface RTOSExpressionReader { + evalExpression(expression: string): Promise; +} + +export interface RTOSThreadParser { + readonly type: RTOSType; + parseThreads(reader: RTOSExpressionReader, options?: RTOSLoadOptions): Promise; +} + +function normalizeRequestedType(value?: RTOSRequestedType | string): RTOSRequestedType { + if (!value) { + return 'auto'; + } + + const normalized = value.toString().trim().toLowerCase(); + switch (normalized) { + case 'none': + return RTOSType.None; + case 'freertos': + return RTOSType.FreeRTOS; + case 'threadx': + return RTOSType.ThreadX; + case 'zephyr': + return RTOSType.Zephyr; + case 'unknown': + return RTOSType.Unknown; + default: + return 'auto'; + } +} + +async function evaluateValue( + reader: RTOSExpressionReader, + expression: string +): Promise { + try { + const result = await reader.evalExpression(expression); + if (result === undefined || result === null) { + return undefined; + } + + if (typeof result === 'string') { + return result; + } + + if (typeof result.result === 'function') { + const value = result.result('value') ?? result.result(''); + if (value === undefined || value === null) { + return undefined; + } + return String(value); + } + + if (result.value !== undefined && result.value !== null) { + return String(result.value); + } + + return String(result); + } catch { + return undefined; + } +} + +function parseNumericValue(value?: string): number | undefined { + if (!value) { + return undefined; + } + + const hexMatch = value.match(/-?0x[0-9a-f]+/i); + if (hexMatch) { + return parseInt(hexMatch[0], 16); + } + + const decMatch = value.match(/-?\d+/); + if (decMatch) { + return parseInt(decMatch[0], 10); + } + + return undefined; +} + +function parseThreadName(value?: string): string | undefined { + if (!value) { + return undefined; + } + + const quoted = value.match(/"([^"]*)"/); + if (quoted) { + return quoted[1]; + } + + const trimmed = value.trim(); + if (!trimmed || /^0x0+$/i.test(trimmed)) { + return undefined; + } + + return trimmed.replace(/^0x[0-9a-f]+\s*/i, '').trim() || undefined; +} + +function calculateStackInfo( + stackBase: number | undefined, + stackEnd: number | undefined, + stackPointer: number | undefined +): RTOSThreadStackInfo | undefined { + if (stackBase === undefined || stackEnd === undefined) { + return undefined; + } + + const size = Math.abs(stackEnd - stackBase); + const used = stackPointer === undefined ? undefined : Math.max(0, Math.abs(stackEnd - stackPointer)); + return { + base: Math.min(stackBase, stackEnd), + size, + used, + }; +} + +function mapFreeRTOSState(value: string | undefined, isCurrent: boolean): RTOSThreadState { + const numeric = parseNumericValue(value); + switch (numeric) { + case 0: + return 'running'; + case 1: + return 'ready'; + case 2: + return 'blocked'; + case 3: + return 'suspended'; + default: + return isCurrent ? 'running' : 'unknown'; + } +} + +function mapThreadXState(value: string | undefined, isCurrent: boolean): RTOSThreadState { + const numeric = parseNumericValue(value); + switch (numeric) { + case 0: + return isCurrent ? 'running' : 'ready'; + case 1: + case 2: + return 'suspended'; + case 3: + return 'ready'; + case 4: + case 5: + case 6: + case 7: + case 8: + case 9: + case 10: + case 11: + case 12: + case 13: + return 'blocked'; + default: + return isCurrent ? 'running' : 'unknown'; + } +} + +function mapZephyrState(value: string | undefined, isCurrent: boolean): RTOSThreadState { + const numeric = parseNumericValue(value); + if (numeric === undefined) { + return isCurrent ? 'running' : 'unknown'; + } + + if (numeric === 0) { + return isCurrent ? 'running' : 'ready'; + } + + if (numeric & 0x8 || numeric & 0x10 || numeric & 0x20) { + return 'suspended'; + } + + return 'blocked'; +} + +function defaultThreadId(options?: RTOSLoadOptions): number { + return options?.currentGdbThreadId ?? options?.gdbThreadIds?.[0] ?? 1; +} + +export class RTOSDetector { + async detect(reader: RTOSExpressionReader): Promise { + if (await this.symbolExists(reader, '&pxCurrentTCB')) { + return RTOSType.FreeRTOS; + } + + if (await this.symbolExists(reader, '&_tx_thread_current_ptr')) { + return RTOSType.ThreadX; + } + + // Check for Zephyr-specific symbols rather than the generic &_kernel to avoid + // false-positives on non-Zephyr firmware that coincidentally exports _kernel. + if (await this.symbolExists(reader, '_kernel.current')) { + return RTOSType.Zephyr; + } + + return RTOSType.None; + } + + private async symbolExists(reader: RTOSExpressionReader, expression: string): Promise { + const value = await evaluateValue(reader, expression); + return value !== undefined; + } +} + +// --------------------------------------------------------------------------- +// FreeRTOS list-walking helpers +// --------------------------------------------------------------------------- + +/** Reads a FreeRTOS TCB by its numeric address and returns an RTOSThread. */ +async function readFreeRTOSTCBByAddress( + reader: RTOSExpressionReader, + tcbPtr: number, + gdbThreadId: number | undefined, + isCurrent: boolean, + source: RTOSType +): Promise { + const hex = `0x${tcbPtr.toString(16)}`; + const stackPointer = parseNumericValue( + await evaluateValue(reader, `((TCB_t*)${hex})->pxTopOfStack`) + ); + const stackBase = parseNumericValue(await evaluateValue(reader, `((TCB_t*)${hex})->pxStack`)); + const stackEnd = parseNumericValue(await evaluateValue(reader, `((TCB_t*)${hex})->pxEndOfStack`)); + const priority = parseNumericValue(await evaluateValue(reader, `((TCB_t*)${hex})->uxPriority`)); + const stateValue = await evaluateValue(reader, `((TCB_t*)${hex})->eCurrentState`); + const name = + parseThreadName(await evaluateValue(reader, `((TCB_t*)${hex})->pcTaskName`)) || + `FreeRTOS Task 0x${tcbPtr.toString(16)}`; + + return { + id: gdbThreadId ?? tcbPtr, + gdbThreadId, + isCurrent, + name, + priority, + stackPointer, + stackInfo: calculateStackInfo(stackBase, stackEnd, stackPointer), + state: mapFreeRTOSState(stateValue, isCurrent), + source, + }; +} + +/** + * Walks a FreeRTOS List_t (suspended / delayed task list) and appends any + * new TCBs found to `threads`. + */ +async function walkFreeRTOSStateList( + reader: RTOSExpressionReader, + listName: string, + visitedTCBPtrs: Set, + threads: RTOSThread[], + maxTasks: number, + source: RTOSType +): Promise { + const itemCount = parseNumericValue( + await evaluateValue(reader, `${listName}.uxNumberOfItems`) + ); + if (!itemCount) { + return; + } + + let itemPtr = parseNumericValue( + await evaluateValue(reader, `${listName}.xListEnd.pxNext`) + ); + + for (let i = 0; i < itemCount && itemPtr && threads.length < maxTasks; i++) { + const hex = `0x${itemPtr.toString(16)}`; + const tcbPtr = parseNumericValue( + await evaluateValue(reader, `((ListItem_t*)${hex})->pvOwner`) + ); + if (tcbPtr && !visitedTCBPtrs.has(tcbPtr)) { + visitedTCBPtrs.add(tcbPtr); + threads.push( + await readFreeRTOSTCBByAddress(reader, tcbPtr, undefined, false, source) + ); + } + itemPtr = parseNumericValue( + await evaluateValue(reader, `((ListItem_t*)${hex})->pxNext`) + ); + } +} + +export class FreeRTOSThreadParser implements RTOSThreadParser { + readonly type = RTOSType.FreeRTOS; + + async parseThreads(reader: RTOSExpressionReader, options?: RTOSLoadOptions): Promise { + const currentPtr = parseNumericValue(await evaluateValue(reader, 'pxCurrentTCB')); + if (!currentPtr) { + return []; + } + + const gdbCurrentThreadId = defaultThreadId(options); + + // Always build the current-task entry via the well-known named expression so the + // existing GDB symbol path continues to work on minimal targets. + const currentStackPointer = parseNumericValue( + await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->pxTopOfStack') + ); + const currentThread: RTOSThread = { + id: gdbCurrentThreadId, + gdbThreadId: gdbCurrentThreadId, + isCurrent: true, + name: + parseThreadName(await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->pcTaskName')) || + `FreeRTOS Task ${gdbCurrentThreadId}`, + priority: parseNumericValue(await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->uxPriority')), + stackPointer: currentStackPointer, + stackInfo: calculateStackInfo( + parseNumericValue(await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->pxStack')), + parseNumericValue(await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->pxEndOfStack')), + currentStackPointer + ), + state: mapFreeRTOSState( + await evaluateValue(reader, '((TCB_t *)pxCurrentTCB)->eCurrentState'), + true + ), + source: this.type, + }; + + const threads: RTOSThread[] = [currentThread]; + const visitedTCBPtrs = new Set([currentPtr]); + + // Try to enumerate all tasks by walking the scheduler lists. + // Falls back gracefully when expressions are unavailable. + const totalTasks = + parseNumericValue(await evaluateValue(reader, 'uxCurrentNumberOfTasks')) ?? 0; + if (totalTasks <= 1) { + return threads; + } + + // Walk ready task lists (one circular list per priority level). + // Query configMAX_PRIORITIES from the target to avoid sending up to 32 sequential + // GDB requests on targets that use fewer priorities. + // + // NOTE: This traversal is intentionally sequential — each GDB expression evaluation + // waits for the previous one before proceeding. The loop is bounded by + // configMAX_PRIORITIES (or 32 when the symbol is unavailable) and exits early once all + // tasks (totalTasks) have been found. visitedTCBPtrs prevents duplicate entries. + // readFreeRTOSTCBByAddress is called once per unique TCB pointer. If latency becomes + // a bottleneck (e.g. on slow transports or with many priorities), this area could be + // refactored to issue evaluations in parallel batches (Promise.all per priority level). + const maxPriorities = + parseNumericValue(await evaluateValue(reader, 'configMAX_PRIORITIES')) ?? 32; + for (let prio = 0; prio < maxPriorities && threads.length < totalTasks; prio++) { + const itemCount = parseNumericValue( + await evaluateValue(reader, `pxReadyTasksLists[${prio}].uxNumberOfItems`) + ); + if (!itemCount) { + continue; + } + + let itemPtr = parseNumericValue( + await evaluateValue(reader, `pxReadyTasksLists[${prio}].xListEnd.pxNext`) + ); + + for (let i = 0; i < itemCount && itemPtr; i++) { + const hex = `0x${itemPtr.toString(16)}`; + const tcbPtr = parseNumericValue( + await evaluateValue(reader, `((ListItem_t*)${hex})->pvOwner`) + ); + if (tcbPtr && !visitedTCBPtrs.has(tcbPtr)) { + visitedTCBPtrs.add(tcbPtr); + threads.push( + await readFreeRTOSTCBByAddress(reader, tcbPtr, undefined, false, this.type) + ); + } + itemPtr = parseNumericValue( + await evaluateValue(reader, `((ListItem_t*)${hex})->pxNext`) + ); + } + } + + // Walk suspended and delayed task lists. + for (const listName of ['xSuspendedTaskList', 'xDelayedTaskList1', 'xDelayedTaskList2']) { + if (threads.length >= totalTasks) { + break; + } + await walkFreeRTOSStateList( + reader, + listName, + visitedTCBPtrs, + threads, + totalTasks, + this.type + ); + } + + return threads; + } +} + +export class ThreadXThreadParser implements RTOSThreadParser { + readonly type = RTOSType.ThreadX; + + async parseThreads(reader: RTOSExpressionReader, options?: RTOSLoadOptions): Promise { + const currentPtr = parseNumericValue(await evaluateValue(reader, '_tx_thread_current_ptr')); + if (!currentPtr) { + return []; + } + + const gdbCurrentThreadId = defaultThreadId(options); + + // Try to walk the full created-thread circular linked list. + const listHeadPtr = parseNumericValue(await evaluateValue(reader, '_tx_thread_created_ptr')); + if (listHeadPtr) { + const threads: RTOSThread[] = []; + const visitedPtrs = new Set(); + let threadPtr: number | undefined = listHeadPtr; + let safety = 0; + + while (threadPtr && !visitedPtrs.has(threadPtr) && safety++ < 64) { + visitedPtrs.add(threadPtr); + const hex = `0x${threadPtr.toString(16)}`; + const isCurrent = threadPtr === currentPtr; + // Use the symbolic pointer expression for the current thread to remain + // compatible with targets that only expose _tx_thread_current_ptr. + const expr = isCurrent ? '_tx_thread_current_ptr' : `(TX_THREAD*)${hex}`; + + const stackPointer = parseNumericValue( + await evaluateValue(reader, `${expr}->tx_thread_stack_ptr`) + ); + const stackBase = parseNumericValue( + await evaluateValue(reader, `${expr}->tx_thread_stack_start`) + ); + const stackEnd = parseNumericValue( + await evaluateValue(reader, `${expr}->tx_thread_stack_end`) + ); + const priority = parseNumericValue( + await evaluateValue(reader, `${expr}->tx_thread_priority`) + ); + const stateValue = await evaluateValue(reader, `${expr}->tx_thread_state`); + const name = + parseThreadName(await evaluateValue(reader, `${expr}->tx_thread_name`)) || + `ThreadX Thread ${isCurrent ? gdbCurrentThreadId : threadPtr}`; + + threads.push({ + id: isCurrent ? gdbCurrentThreadId : threadPtr, + gdbThreadId: isCurrent ? gdbCurrentThreadId : undefined, + isCurrent, + name, + priority, + stackPointer, + stackInfo: calculateStackInfo(stackBase, stackEnd, stackPointer), + state: mapThreadXState(stateValue, isCurrent), + source: this.type, + }); + + // tx_thread_created_next forms a circular list; the visited-set detects wrap-around. + threadPtr = parseNumericValue( + await evaluateValue(reader, `((TX_THREAD*)${hex})->tx_thread_created_next`) + ); + } + + if (threads.length > 0) { + return threads; + } + } + + // Fallback: return only the current thread via the existing named expressions. + const stackPointer = parseNumericValue( + await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_stack_ptr') + ); + const stackBase = parseNumericValue( + await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_stack_start') + ); + const stackEnd = parseNumericValue( + await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_stack_end') + ); + const priority = parseNumericValue( + await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_priority') + ); + const stateValue = await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_state'); + const name = + parseThreadName(await evaluateValue(reader, '_tx_thread_current_ptr->tx_thread_name')) || + `ThreadX Thread ${gdbCurrentThreadId}`; + + return [ + { + id: gdbCurrentThreadId, + gdbThreadId: gdbCurrentThreadId, + isCurrent: true, + name, + priority, + stackPointer, + stackInfo: calculateStackInfo(stackBase, stackEnd, stackPointer), + state: mapThreadXState(stateValue, true), + source: this.type, + }, + ]; + } +} + +export class ZephyrThreadParser implements RTOSThreadParser { + readonly type = RTOSType.Zephyr; + + async parseThreads(reader: RTOSExpressionReader, options?: RTOSLoadOptions): Promise { + const currentPtr = parseNumericValue(await evaluateValue(reader, '_kernel.current')); + if (!currentPtr) { + return []; + } + + const gdbCurrentThreadId = defaultThreadId(options); + + // Try to walk the full NULL-terminated thread list via _kernel.threads. + const listHeadPtr = parseNumericValue(await evaluateValue(reader, '_kernel.threads')); + if (listHeadPtr) { + const threads: RTOSThread[] = []; + const visitedPtrs = new Set(); + let threadPtr: number | undefined = listHeadPtr; + let safety = 0; + + while (threadPtr && !visitedPtrs.has(threadPtr) && safety++ < 64) { + visitedPtrs.add(threadPtr); + const hex = `0x${threadPtr.toString(16)}`; + const isCurrent = threadPtr === currentPtr; + // Use the symbolic pointer expression for the current thread for compat. + const expr = isCurrent + ? '(struct k_thread *)_kernel.current' + : `(struct k_thread*)${hex}`; + + const stackPointer = parseNumericValue( + await evaluateValue(reader, `${expr}->callee_saved.psp`) + ); + const priority = parseNumericValue(await evaluateValue(reader, `${expr}->base.prio`)); + const stateValue = await evaluateValue(reader, `${expr}->base.thread_state`); + const name = + parseThreadName(await evaluateValue(reader, `${expr}->name`)) || + `Zephyr Thread ${isCurrent ? gdbCurrentThreadId : threadPtr}`; + + threads.push({ + id: isCurrent ? gdbCurrentThreadId : threadPtr, + gdbThreadId: isCurrent ? gdbCurrentThreadId : undefined, + isCurrent, + name, + priority, + stackPointer, + state: mapZephyrState(stateValue, isCurrent), + source: this.type, + }); + + threadPtr = parseNumericValue( + await evaluateValue(reader, `((struct k_thread*)${hex})->next_thread`) + ); + } + + if (threads.length > 0) { + return threads; + } + } + + // Fallback: return only the current thread via the existing named expressions. + const stackPointer = parseNumericValue( + await evaluateValue(reader, '((struct k_thread *)_kernel.current)->callee_saved.psp') + ); + const priority = parseNumericValue( + await evaluateValue(reader, '((struct k_thread *)_kernel.current)->base.prio') + ); + const stateValue = await evaluateValue( + reader, + '((struct k_thread *)_kernel.current)->base.thread_state' + ); + const name = + parseThreadName( + await evaluateValue(reader, '((struct k_thread *)_kernel.current)->name') + ) || `Zephyr Thread ${gdbCurrentThreadId}`; + + return [ + { + id: gdbCurrentThreadId, + gdbThreadId: gdbCurrentThreadId, + isCurrent: true, + name, + priority, + stackPointer, + state: mapZephyrState(stateValue, true), + source: this.type, + }, + ]; + } +} + +export class RTOSManager { + private parsers = new Map(); + + constructor(private detector: RTOSDetector = new RTOSDetector()) { + const supportedParsers: RTOSThreadParser[] = [ + new FreeRTOSThreadParser(), + new ThreadXThreadParser(), + new ZephyrThreadParser(), + ]; + + supportedParsers.forEach((parser) => { + this.parsers.set(parser.type, parser); + }); + } + + async load(reader: MI2 | RTOSExpressionReader, options?: RTOSLoadOptions): Promise { + if (options?.enabled === false) { + return { type: RTOSType.None, threads: [] }; + } + + const requestedType = normalizeRequestedType(options?.requestedType); + const resolvedType = + requestedType === 'auto' ? await this.detector.detect(reader as RTOSExpressionReader) : requestedType; + const parser = this.parsers.get(resolvedType); + + if (!parser) { + return { type: resolvedType, threads: [] }; + } + + const threads = await parser.parseThreads(reader as RTOSExpressionReader, options); + return { type: resolvedType, threads }; + } +} \ No newline at end of file diff --git a/src/backend/symbols.ts b/src/backend/symbols.ts index 826dd7e..6722e78 100644 --- a/src/backend/symbols.ts +++ b/src/backend/symbols.ts @@ -130,20 +130,24 @@ export class SymbolTable { return this.symbols.filter((sym) => sym.type === SymbolType.Function); } - /** Returns all global object symbols. */ + /** Returns all global object symbols, excluding LTO-internal symbols. */ getGlobalVariables(): SymbolInformation[] { return this.symbols.filter( - (sym) => sym.type === SymbolType.Object && sym.scope === SymbolScope.Global + (sym) => + sym.type === SymbolType.Object && + sym.scope === SymbolScope.Global && + !sym.name.includes('.lto_priv.') ); } - /** Returns file-local object symbols. */ + /** Returns file-local object symbols, excluding LTO-internal symbols. */ getStaticVariables(file: string): SymbolInformation[] { return this.symbols.filter( (sym) => sym.type === SymbolType.Object && sym.scope === SymbolScope.Local && - sym.file === file + sym.file === file && + !sym.name.includes('.lto_priv.') ); } diff --git a/src/extension.ts b/src/extension.ts index deb169a..42a5074 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,10 +4,11 @@ import { encodeDisassembly } from './utils'; import { PlatformIODebugConfigurationProvider } from './frontend/configprovider'; import { DisassemblyContentProvider } from './frontend/disassembly_content_provider'; import { DisassemblyTreeProvider } from './frontend/disassembly_tree_provider'; -import { MemoryContentProvider } from './frontend/memory_content_provider'; +import { MemoryContentProvider, MemoryDataType, Endianness } from './frontend/memory_content_provider'; import { MemoryTreeProvider } from './frontend/memory_tree_provider'; import { PeripheralTreeProvider, RecordType as PeripheralRecordType } from './frontend/peripheral'; import { RegisterTreeProvider, RecordType as RegisterRecordType } from './frontend/registers'; +import { getDiagnosticsManager } from './frontend/diagnostics'; /** * Main entry point and controller for the PlatformIO Debug VS Code extension. @@ -21,6 +22,7 @@ class PlatformIODebugExtension { private memoryTreeProvider: MemoryTreeProvider; private disassemblyTreeProvider: DisassemblyTreeProvider; private memoryContentProvider: MemoryContentProvider; + private diagnostics = getDiagnosticsManager(); constructor(context: vscode.ExtensionContext) { this.context = context; @@ -30,9 +32,18 @@ class PlatformIODebugExtension { this.disassemblyTreeProvider = new DisassemblyTreeProvider(); this.memoryContentProvider = new MemoryContentProvider(); + // Apply workspace configuration defaults for memory and diagnostics settings. + const cfg = vscode.workspace.getConfiguration('platformio-debug'); + const defaultDataType = cfg.get('memory.defaultDataType', 'u8'); + const defaultEndianness = cfg.get('memory.defaultEndianness', 'little'); + this.memoryContentProvider.setDataType(defaultDataType as MemoryDataType); + this.memoryContentProvider.setEndianness(defaultEndianness as Endianness); + this.diagnostics.setShowDevDebugOutput(cfg.get('diagnostics.showDevDebugOutput', false)); + const peripheralTreeView = vscode.window.createTreeView('platformio-debug.peripherals', { treeDataProvider: this.peripheralProvider, }); + this.peripheralProvider.setTreeView(peripheralTreeView); context.subscriptions.push( vscode.debug.registerDebugConfigurationProvider( @@ -56,23 +67,49 @@ class PlatformIODebugExtension { vscode.commands.registerCommand('platformio-debug.peripherals.selectedNode', this.peripheralsSelectedNode.bind(this)), vscode.commands.registerCommand('platformio-debug.peripherals.copyValue', this.peripheralsCopyValue.bind(this)), vscode.commands.registerCommand('platformio-debug.peripherals.setFormat', this.peripheralsSetFormat.bind(this)), + vscode.commands.registerCommand('platformio-debug.peripherals.search', this.peripheralsSearch.bind(this)), vscode.commands.registerCommand('platformio-debug.registers.selectedNode', this.registersSelectedNode.bind(this)), vscode.commands.registerCommand('platformio-debug.registers.copyValue', this.registersCopyValue.bind(this)), vscode.commands.registerCommand('platformio-debug.registers.setFormat', this.registersSetFormat.bind(this)), vscode.commands.registerCommand('platformio-debug.memory.deleteHistoryItem', this.memoryDeleteHistoryItem.bind(this)), vscode.commands.registerCommand('platformio-debug.memory.clearHistory', this.memoryClearHistory.bind(this)), vscode.commands.registerCommand('platformio-debug.examineMemory', this.examineMemory.bind(this)), + vscode.commands.registerCommand('platformio-debug.memory.setDataType', this.memorySetDataType.bind(this)), + vscode.commands.registerCommand('platformio-debug.memory.toggleEndianness', this.memoryToggleEndianness.bind(this)), + vscode.commands.registerCommand('platformio-debug.memory.edit', this.memoryWriteByte.bind(this)), + vscode.commands.registerCommand('platformio-debug.memory.writeByte', this.memoryWriteByte.bind(this)), vscode.commands.registerCommand('platformio-debug.viewDisassembly', this.showDisassembly.bind(this)), vscode.commands.registerCommand('platformio-debug.setForceDisassembly', this.setForceDisassembly.bind(this)), + vscode.commands.registerCommand('platformio-debug.diagnostics.showLog', this.showDiagnosticsLog.bind(this)), + vscode.commands.registerCommand('platformio-debug.diagnostics.exportLog', this.exportDiagnosticsLog.bind(this)), + vscode.commands.registerCommand('platformio-debug.diagnostics.clearLog', this.clearDiagnosticsLog.bind(this)), + vscode.commands.registerCommand('platformio-debug.reloadSVD', this.reloadSVD.bind(this)), vscode.debug.onDidReceiveDebugSessionCustomEvent(this.receivedCustomEvent.bind(this)), vscode.debug.onDidStartDebugSession(this.debugSessionStarted.bind(this)), vscode.debug.onDidTerminateDebugSession(this.debugSessionTerminated.bind(this)), vscode.window.onDidChangeActiveTextEditor(this.activeEditorChanged.bind(this)), + vscode.workspace.onDidChangeConfiguration(e => { + if (e.affectsConfiguration('platformio-debug.diagnostics.showDevDebugOutput')) { + this.diagnostics.setShowDevDebugOutput( + vscode.workspace.getConfiguration('platformio-debug').get('diagnostics.showDevDebugOutput', false) + ); + } + }), vscode.window.onDidChangeTextEditorSelection((e) => { if (e && e.textEditor.document.fileName.endsWith('.dbgmem')) { this.memoryContentProvider.handleSelection(e); } + }), + vscode.workspace.onDidChangeTextDocument(e => { + if (e.document.uri.scheme === 'examinememory') { + const editor = vscode.window.visibleTextEditors.find( + ed => ed.document.uri.toString() === e.document.uri.toString() + ); + if (editor) { + this.memoryContentProvider.applyDiffDecorations(editor); + } + } }) ); } @@ -267,6 +304,125 @@ class PlatformIODebugExtension { ); } + /** Refreshes all open memory editors. */ + private refreshOpenMemoryEditors(): void { + vscode.workspace.textDocuments.forEach((doc) => { + if (doc.fileName.endsWith('.dbgmem')) { + this.memoryContentProvider.update(doc); + } + }); + } + + /** Sets the data type for memory interpretation. */ + private memorySetDataType(): void { + // Capture the active memory editor URI before opening the quick pick + // (which may shift focus away from the editor). + const activeMemUri = vscode.window.activeTextEditor?.document?.uri?.scheme === 'examinememory' + ? vscode.window.activeTextEditor.document.uri.toString() + : null; + + const dataTypes = [ + { label: 'u8 (unsigned 8-bit)', value: MemoryDataType.U8 }, + { label: 'u16 (unsigned 16-bit)', value: MemoryDataType.U16 }, + { label: 'u32 (unsigned 32-bit)', value: MemoryDataType.U32 }, + { label: 'u64 (unsigned 64-bit)', value: MemoryDataType.U64 }, + { label: 'i8 (signed 8-bit)', value: MemoryDataType.I8 }, + { label: 'i16 (signed 16-bit)', value: MemoryDataType.I16 }, + { label: 'i32 (signed 32-bit)', value: MemoryDataType.I32 }, + { label: 'i64 (signed 64-bit)', value: MemoryDataType.I64 }, + { label: 'float (32-bit float)', value: MemoryDataType.Float }, + { label: 'double (64-bit double)', value: MemoryDataType.Double } + ]; + + vscode.window.showQuickPick(dataTypes.map(dt => dt.label)).then(selected => { + if (selected) { + const dataType = dataTypes.find(dt => dt.label === selected)?.value; + if (dataType) { + if (activeMemUri) { + this.memoryContentProvider.setDataTypeForUri(activeMemUri, dataType); + } else { + this.memoryContentProvider.setDataType(dataType); + } + this.refreshOpenMemoryEditors(); + } + } + }); + } + + /** Toggles endianness for memory interpretation. */ + private memoryToggleEndianness(): void { + const activeMemUri = vscode.window.activeTextEditor?.document?.uri?.scheme === 'examinememory' + ? vscode.window.activeTextEditor.document.uri.toString() + : null; + + let endianness: Endianness; + if (activeMemUri) { + this.memoryContentProvider.toggleEndiannessForUri(activeMemUri); + endianness = this.memoryContentProvider.getEndiannessForUri(activeMemUri); + } else { + this.memoryContentProvider.toggleEndianness(); + endianness = this.memoryContentProvider.getEndianness(); + } + vscode.window.showInformationMessage(`Memory view endianness: ${endianness}`); + + this.refreshOpenMemoryEditors(); + } + + /** Writes a byte to memory at the given address. */ + private async memoryWriteByte(args: { address: number; value: number }): Promise { + if (!this.isPIODebugSession()) { + vscode.window.showErrorMessage('No debugging session available'); + return; + } + + if (!args || typeof args.address !== 'number' || typeof args.value !== 'number') { + // Interactive mode - prompt for address and value + const addressInput = await vscode.window.showInputBox({ + prompt: 'Memory address to write (hex with 0x prefix or decimal)', + validateInput: (value) => { + if (/^0x[0-9a-f]+$/i.test(value) || /^[0-9]+$/i.test(value)) { + return null; + } + return 'Invalid address format'; + } + }); + + if (!addressInput) return; + + const valueInput = await vscode.window.showInputBox({ + prompt: 'Value to write (0x00 - 0xFF)', + validateInput: (value) => { + if (/^0x[0-9a-f]{1,2}$/i.test(value)) { + return null; + } + return 'Invalid hex byte format (use 0x00 - 0xFF)'; + } + }); + + if (!valueInput) return; + + const address = addressInput.startsWith('0x') + ? parseInt(addressInput.substring(2), 16) + : parseInt(addressInput, 10); + const value = parseInt(valueInput.substring(2), 16); + + const success = await this.memoryContentProvider.writeByte(address, value); + if (success) { + vscode.window.showInformationMessage(`Wrote 0x${value.toString(16).padStart(2, '0').toUpperCase()} to ${addressInput}`); + this.refreshOpenMemoryEditors(); + } + } else { + // Direct call with args + const success = await this.memoryContentProvider.writeByte(args.address, args.value); + if (success) { + vscode.window.showInformationMessage( + `Wrote 0x${args.value.toString(16).padStart(2, '0').toUpperCase()} to 0x${args.address.toString(16).toUpperCase()}` + ); + this.refreshOpenMemoryEditors(); + } + } + } + /** Updates a peripheral node and refreshes. */ private peripheralsUpdateNode(node: any): void { node.node.performUpdate().then( @@ -316,6 +472,11 @@ class PlatformIODebugExtension { this.peripheralProvider.refresh(); } + /** Opens the peripheral search/filter QuickPick. */ + private peripheralsSearch(): Promise { + return this.peripheralProvider.search(); + } + /** Handles selection of a register node. */ private registersSelectedNode(node: any): void { if (node.recordType !== RegisterRecordType.Field) { @@ -352,8 +513,18 @@ class PlatformIODebugExtension { this.registerProvider.debugSessionStarted( this.context.workspaceState.get('debugRegistersTreeState') ); + let svdPath: string | undefined = args.svdPath; + if (!svdPath) { + svdPath = this.peripheralProvider.findSVDFile(args.device); + if (svdPath) { + this.diagnostics.info( + 'SVD', + `Auto-discovered SVD file: ${svdPath}` + ); + } + } this.peripheralProvider.debugSessionStarted( - args.svdPath, + svdPath, this.context.workspaceState.get('debugPeripheralsTreeState') ); this.memoryTreeProvider.debugSessionStarted( @@ -446,6 +617,48 @@ class PlatformIODebugExtension { } this.adapterOutputChannel.append(content); } + + /** Shows the diagnostic log output channel. */ + private showDiagnosticsLog(): void { + this.diagnostics.showOutputChannel(); + } + + /** Exports diagnostic log to clipboard. */ + private exportDiagnosticsLog(): void { + const logContent = this.diagnostics.exportLog(); + vscode.env.clipboard.writeText(logContent).then(() => { + this.diagnostics.showInfo('Diagnostic log copied to clipboard'); + }); + } + + /** Clears the diagnostic log. */ + private clearDiagnosticsLog(): void { + this.diagnostics.clearLog(); + this.diagnostics.showInfo('Diagnostic log cleared'); + } + + /** Reloads the peripheral tree using the supplied SVD file path. */ + private async reloadSVD(svdPath: string): Promise { + let resolvedPath = svdPath; + if (!resolvedPath) { + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + filters: { + 'SVD Files': ['svd', 'SVD'], + 'All Files': ['*'], + }, + openLabel: 'Select SVD File', + }); + if (!uris || uris.length === 0) { + return; + } + resolvedPath = uris[0].fsPath; + } + this.peripheralProvider.reloadSVD(resolvedPath); + this.diagnostics.showInfo(`Reloaded SVD: ${resolvedPath}`); + } } /** diff --git a/src/frontend/diagnostics.ts b/src/frontend/diagnostics.ts new file mode 100644 index 0000000..71de816 --- /dev/null +++ b/src/frontend/diagnostics.ts @@ -0,0 +1,496 @@ +import * as vscode from 'vscode' + +/** + * Represents an action that can be taken when an error occurs. + */ +export interface ErrorAction { + label: string + callback: () => void +} + +/** + * Log entry for diagnostic output. + */ +export interface LogEntry { + timestamp: Date + level: 'debug' | 'info' | 'warn' | 'error' + source: string + message: string + details?: string +} + +/** + * Error patterns for common GDB/MI errors with suggested actions. + */ +interface ErrorPattern { + pattern: RegExp + message: string + actions: ErrorAction[] + severity: 'error' | 'warning' +} + +/** + * Centralized diagnostics and error handling for the debug extension. + * Provides structured error messages, logging, and actionable error recovery. + */ +export class DiagnosticsManager { + private outputChannel: vscode.OutputChannel + private logEntries: LogEntry[] = [] + private maxLogEntries: number = 1000 + private errorPatterns: ErrorPattern[] = [] + private showDevDebugOutput: boolean = false + + constructor() { + this.outputChannel = vscode.window.createOutputChannel('PlatformIO Debug Diagnostics') + this.initializeErrorPatterns() + } + + /** + * Sets whether to show detailed debug output. + */ + setShowDevDebugOutput(enabled: boolean): void { + this.showDevDebugOutput = enabled + } + + /** + * Logs a message to the diagnostic output. + */ + log(level: LogEntry['level'], source: string, message: string, details?: string): void { + const entry: LogEntry = { + timestamp: new Date(), + level, + source, + message, + details + } + + this.logEntries.push(entry) + + // Maintain log size limit + if (this.logEntries.length > this.maxLogEntries) { + this.logEntries.shift() + } + + // Always log errors and warnings; log info in normal mode; log debug only if dev output enabled + if (level === 'error' || level === 'warn' || level === 'info' || this.showDevDebugOutput) { + const timestamp = entry.timestamp.toISOString().split('T')[1].split('.')[0] + const logLine = `[${timestamp}] [${level.toUpperCase()}] [${source}] ${message}` + this.outputChannel.appendLine(logLine) + if (details && this.showDevDebugOutput) { + this.outputChannel.appendLine(` Details: ${details}`) + } + } + } + + /** + * Logs a debug message. + */ + debug(source: string, message: string, details?: string): void { + this.log('debug', source, message, details) + } + + /** + * Logs an informational message. + */ + info(source: string, message: string, details?: string): void { + this.log('info', source, message, details) + } + + /** + * Logs a warning message. + */ + warn(source: string, message: string, details?: string): void { + this.log('warn', source, message, details) + } + + /** + * Logs an error message. + */ + error(source: string, message: string, details?: string): void { + this.log('error', source, message, details) + } + + /** + * Shows the diagnostic output channel. + */ + showOutputChannel(): void { + this.outputChannel.show() + } + + /** + * Clears the diagnostic log. + */ + clearLog(): void { + this.logEntries = [] + this.outputChannel.clear() + } + + /** + * Gets all log entries, optionally filtered by level. + */ + getLogEntries(level?: LogEntry['level']): LogEntry[] { + if (level) { + return this.logEntries.filter(entry => entry.level === level) + } + return [...this.logEntries] + } + + /** + * Exports the diagnostic log to a string for bug reports. + */ + exportLog(): string { + const lines: string[] = [ + 'PlatformIO Debug Diagnostic Log', + `Generated: ${new Date().toISOString()}`, + `Total Entries: ${this.logEntries.length}`, + '---' + ] + + for (const entry of this.logEntries) { + const timestamp = entry.timestamp.toISOString() + lines.push(`[${timestamp}] [${entry.level.toUpperCase()}] [${entry.source}] ${entry.message}`) + if (entry.details) { + lines.push(` Details: ${entry.details}`) + } + } + + return lines.join('\n') + } + + /** + * Shows an error message with optional actions. + */ + showError(message: string, actions?: ErrorAction[]): void { + this.error('DiagnosticsManager', `Showing error: ${message}`) + + if (actions && actions.length > 0) { + const actionLabels = actions.map(a => a.label) + Promise.resolve(vscode.window.showErrorMessage(message, ...actionLabels)) + .then(selected => { + if (selected) { + const action = actions.find(a => a.label === selected) + if (action) { + this.info('DiagnosticsManager', `Executing action: ${action.label}`) + try { action.callback() } catch (err) { + this.error('DiagnosticsManager', `Error dialog action failed: ${err}`) + } + } + } + }) + .catch(err => this.error('DiagnosticsManager', `Error dialog promise failed: ${err}`)) + } else { + vscode.window.showErrorMessage(message) + } + } + + /** + * Shows a warning message with optional actions. + */ + showWarning(message: string, actions?: ErrorAction[]): void { + this.warn('DiagnosticsManager', `Showing warning: ${message}`) + + if (actions && actions.length > 0) { + const actionLabels = actions.map(a => a.label) + Promise.resolve(vscode.window.showWarningMessage(message, ...actionLabels)) + .then(selected => { + if (selected) { + const action = actions.find(a => a.label === selected) + if (action) { + try { action.callback() } catch (err) { + this.error('DiagnosticsManager', `Warning dialog action failed: ${err}`) + } + } + } + }) + .catch(err => this.error('DiagnosticsManager', `Warning dialog promise failed: ${err}`)) + } else { + vscode.window.showWarningMessage(message) + } + } + + /** + * Shows an informational message with optional actions. + */ + showInfo(message: string, actions?: ErrorAction[]): void { + this.info('DiagnosticsManager', `Showing info: ${message}`) + + if (actions && actions.length > 0) { + const actionLabels = actions.map(a => a.label) + Promise.resolve(vscode.window.showInformationMessage(message, ...actionLabels)) + .then(selected => { + if (selected) { + const action = actions.find(a => a.label === selected) + if (action) { + try { action.callback() } catch (err) { + this.error('DiagnosticsManager', `Info dialog action failed: ${err}`) + } + } + } + }) + .catch(err => this.error('DiagnosticsManager', `Info dialog promise failed: ${err}`)) + } else { + vscode.window.showInformationMessage(message) + } + } + + /** + * Handles a GDB/MI error by matching against known patterns. + * Returns true if the error was handled by a pattern, false otherwise. + */ + handleGDBError(errorMessage: string): boolean { + this.error('GDB', 'Error received', errorMessage) + + for (const pattern of this.errorPatterns) { + if (pattern.pattern.test(errorMessage)) { + this.info('DiagnosticsManager', `Matched error pattern: ${pattern.message}`) + + if (pattern.severity === 'error') { + this.showError(pattern.message, pattern.actions) + } else { + this.showWarning(pattern.message, pattern.actions) + } + return true + } + } + + // No pattern matched, show generic error + this.showError(`Debug error: ${errorMessage}`, [ + { + label: 'Show Diagnostics', + callback: () => this.showOutputChannel() + }, + { + label: 'Copy Error', + callback: () => { + vscode.env.clipboard.writeText(errorMessage) + this.showInfo('Error message copied to clipboard') + } + } + ]) + + return false + } + + /** + * Handles connection errors with specific troubleshooting steps. + */ + handleConnectionError(error: string, host?: string, port?: number): void { + this.error('Connection', `Connection error to ${host}:${port}`, error) + + this.showError( + `Failed to connect to debug server${host && port ? ` at ${host}:${port}` : ''}.`, + [ + { + label: 'Check Connection', + callback: () => { + this.showInfo( + 'Troubleshooting:\n' + + '1. Verify target device is connected\n' + + '2. Check OpenOCD/GDB server is running\n' + + '3. Verify port and host settings\n' + + '4. Check firewall settings' + ) + } + }, + { + label: 'Restart Debug', + callback: () => { + vscode.commands.executeCommand('workbench.action.debug.restart') + } + }, + { + label: 'Show Diagnostics', + callback: () => this.showOutputChannel() + } + ] + ) + } + + /** + * Handles SVD file loading errors. + */ + handleSVDError(error: string, svdPath?: string): void { + this.error('SVD', `SVD file error${svdPath ? ` for ${svdPath}` : ''}`, error) + + const message = svdPath + ? `Failed to load SVD file: ${svdPath}` + : 'Failed to load SVD file' + + this.showError(message, [ + { + label: 'Locate SVD File', + callback: () => { + vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + filters: { + 'SVD Files': ['svd'], + 'All Files': ['*'] + } + }).then(uri => { + if (uri && uri[0]) { + // Notify extension to reload with new SVD path + vscode.commands.executeCommand('platformio-debug.reloadSVD', uri[0].fsPath) + } + }) + } + }, + { + label: 'Skip SVD Load', + callback: () => { + this.showInfo('SVD loading skipped. Peripheral view will not be available.') + } + } + ]) + } + + /** + * Handles memory access errors. + */ + handleMemoryError(error: string, address?: number): void { + this.error('Memory', `Memory access error${address !== undefined ? ` at 0x${address.toString(16)}` : ''}`, error) + + const message = address !== undefined + ? `Cannot access memory at address 0x${address.toString(16)}` + : 'Cannot access memory' + + this.showError(message, [ + { + label: 'Target May Not Be Halted', + callback: () => { + this.showInfo( + 'To access memory, the target must be halted.\n' + + 'Try pausing execution first (F6) or setting a breakpoint.' + ) + } + }, + { + label: 'Check Address', + callback: () => { + this.showInfo( + 'Verify the memory address is valid:\n' + + '1. Check device memory map\n' + + '2. Verify address is accessible\n' + + '3. Check if region requires special permissions' + ) + } + } + ]) + } + + /** + * Initializes the error pattern matchers. + */ + private initializeErrorPatterns(): void { + this.errorPatterns = [ + { + pattern: /connection\s+(refused|failed|timed\s+out)/i, + message: 'Debug server connection failed. The target may not be connected.', + severity: 'error', + actions: [ + { + label: 'Check Connection', + callback: () => this.showConnectionTroubleshooting() + }, + { + label: 'Retry', + callback: () => vscode.commands.executeCommand('workbench.action.debug.restart') + } + ] + }, + { + pattern: /no\s+such\s+file\s+or\s+directory/i, + message: 'Required file not found. Check your project configuration.', + severity: 'error', + actions: [ + { + label: 'Open Settings', + callback: () => vscode.commands.executeCommand('workbench.action.openSettings', 'platformio') + } + ] + }, + { + pattern: /cannot\s+access\s+memory/i, + message: 'Cannot access target memory. The target may not be halted.', + severity: 'error', + actions: [ + { + label: 'Pause Target', + callback: () => vscode.commands.executeCommand('workbench.action.debug.pause') + }, + { + label: 'Set Breakpoint', + callback: () => vscode.commands.executeCommand('editor.debug.action.toggleBreakpoint') + } + ] + }, + { + pattern: /remote\s+replied\s+with\s+error/i, + message: 'GDB server protocol error. There may be a version mismatch.', + severity: 'warning', + actions: [ + { + label: 'Show Diagnostics', + callback: () => this.showOutputChannel() + } + ] + }, + { + pattern: /unrecognized\s+command/i, + message: 'Unrecognized GDB command. Check GDB server compatibility.', + severity: 'warning', + actions: [ + { + label: 'Check GDB Version', + callback: () => this.showInfo('Ensure GDB version is compatible with your target.') + } + ] + } + ] + } + + /** + * Shows connection troubleshooting information. + */ + private showConnectionTroubleshooting(): void { + this.showInfo( + 'Connection Troubleshooting:\n\n' + + '1. Verify target device is connected via USB/JTAG\n' + + '2. Check OpenOCD or GDB server is running\n' + + '3. Verify port settings in launch.json\n' + + '4. Check for driver issues (Zadig for Windows)\n' + + '5. Ensure correct permissions (Linux: udev rules)', + [ + { + label: 'Open Documentation', + callback: () => { + vscode.env.openExternal(vscode.Uri.parse('https://docs.platformio.org/en/latest/plus/debugging.html')) + } + } + ] + ) + } +} + +/** + * Singleton instance of the diagnostics manager. + */ +let diagnosticsManager: DiagnosticsManager | null = null + +/** + * Gets the singleton diagnostics manager instance. + * Creates the instance on first call. + */ +export function getDiagnosticsManager(): DiagnosticsManager { + if (!diagnosticsManager) { + diagnosticsManager = new DiagnosticsManager() + } + return diagnosticsManager +} + +/** + * Resets the singleton instance (useful for testing). + */ +export function resetDiagnosticsManager(): void { + diagnosticsManager = null +} diff --git a/src/frontend/memory_content_provider.ts b/src/frontend/memory_content_provider.ts index c0ca007..d5ce77d 100644 --- a/src/frontend/memory_content_provider.ts +++ b/src/frontend/memory_content_provider.ts @@ -1,11 +1,32 @@ import * as vscode from 'vscode'; import { hexFormat, parseQuery } from '../utils'; +/** Supported data types for memory interpretation. */ +export enum MemoryDataType { + U8 = 'u8', + U16 = 'u16', + U32 = 'u32', + U64 = 'u64', + I8 = 'i8', + I16 = 'i16', + I32 = 'i32', + I64 = 'i64', + Float = 'float', + Double = 'double', +} + +/** Endianness options. */ +export enum Endianness { + Little = 'little', + Big = 'big', +} + /** TextDocumentContentProvider for examinememory://. */ export class MemoryContentProvider implements vscode.TextDocumentContentProvider { private _onDidChange = new vscode.EventEmitter(); public onDidChange = this._onDidChange.event; + private readonly headerLines = 2; private firstBytePos = 10; private lastBytePos = this.firstBytePos + 48 - 1; private firstAsciiPos = this.lastBytePos + 3; @@ -20,6 +41,130 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider dark: { borderColor: 'lightblue' }, }); + private diffDecorationType = vscode.window.createTextEditorDecorationType({ + overviewRulerColor: 'orange', + overviewRulerLane: vscode.OverviewRulerLane.Right, + light: { backgroundColor: 'rgba(255, 140, 0, 0.25)' }, + dark: { backgroundColor: 'rgba(255, 165, 0, 0.25)' }, + }); + + // New: Data type interpretation settings + private dataType: MemoryDataType = MemoryDataType.U8; + private endianness: Endianness = Endianness.Little; + + // ASCII column toggle + private showAscii: boolean = true; + + // New: Track memory contents for data type display + private currentBytes: number[] = []; + private currentAddress: number = 0; + + // Memory diff tracking — per-URI to prevent cross-contamination between open windows + private previousBytes: number[] = []; + private readonly uriBytes = new Map(); + private readonly uriChangedOffsets = new Map>(); + // Per-URI display settings; fall back to global defaults when absent. + private readonly uriSettings = new Map(); + + /** Sets the data type for interpretation. */ + setDataType(type: MemoryDataType): void { + this.dataType = type; + } + + /** Gets the current data type. */ + getDataType(): MemoryDataType { + return this.dataType; + } + + /** Sets the endianness for multi-byte data types. */ + setEndianness(endianness: Endianness): void { + this.endianness = endianness; + } + + /** Gets the current endianness. */ + getEndianness(): Endianness { + return this.endianness; + } + + /** Toggles between little and big endian. */ + toggleEndianness(): void { + this.endianness = this.endianness === Endianness.Little + ? Endianness.Big + : Endianness.Little; + } + + /** Sets whether to show the ASCII column. */ + setShowAscii(show: boolean): void { + this.showAscii = show; + } + + /** Gets whether the ASCII column is shown. */ + getShowAscii(): boolean { + return this.showAscii; + } + + /** Toggles the ASCII column visibility. */ + toggleAsciiView(): void { + this.showAscii = !this.showAscii; + } + + /** Sets the data type for a specific document URI. */ + setDataTypeForUri(uriKey: string, type: MemoryDataType): void { + const s = this.uriSettings.get(uriKey) ?? {}; + s.dataType = type; + this.uriSettings.set(uriKey, s); + } + + /** Gets the effective data type for a URI, falling back to the global default. */ + getDataTypeForUri(uriKey: string): MemoryDataType { + return this.uriSettings.get(uriKey)?.dataType ?? this.dataType; + } + + /** Toggles endianness for a specific document URI. */ + toggleEndiannessForUri(uriKey: string): void { + const s = this.uriSettings.get(uriKey) ?? {}; + s.endianness = (s.endianness ?? this.endianness) === Endianness.Little + ? Endianness.Big + : Endianness.Little; + this.uriSettings.set(uriKey, s); + } + + /** Gets the effective endianness for a URI, falling back to the global default. */ + getEndiannessForUri(uriKey: string): Endianness { + return this.uriSettings.get(uriKey)?.endianness ?? this.endianness; + } + + /** Toggles the ASCII column for a specific document URI. */ + toggleAsciiViewForUri(uriKey: string): void { + const s = this.uriSettings.get(uriKey) ?? {}; + s.showAscii = !(s.showAscii ?? this.showAscii); + this.uriSettings.set(uriKey, s); + } + + /** Returns byte offsets that differ between the last two reads. */ + getChangedOffsets(): number[] { + return this.computeChangedOffsets(this.currentBytes, this.previousBytes); + } + + /** Internal helper: returns changed offsets between two byte arrays. */ + private computeChangedOffsets(current: number[], previous: number[]): number[] { + if (previous.length === 0 || previous.length !== current.length) { + return []; + } + const changed: number[] = []; + for (let i = 0; i < current.length; i++) { + if (current[i] !== previous[i]) { + changed.push(i); + } + } + return changed; + } + + /** Returns the previous bytes snapshot (from the last read). */ + getPreviousBytes(): number[] { + return this.previousBytes.slice(); + } + /** Returns hex+ASCII memory dump for the URI. */ provideTextDocumentContent(uri: vscode.Uri): Thenable { return new Promise((resolve, reject) => { @@ -31,16 +176,40 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider ? parseInt(params.length.substring(2), 16) : parseInt(params.length, 10); + this.currentAddress = address; + vscode.debug.activeDebugSession .customRequest('read-memory', { address, length: length || 32 }) .then( (result: any) => { const bytes: number[] = result.bytes; - let rowAddress = address - (address % 16); - const offset = address - rowAddress; + this.currentBytes = bytes; + + // Compute diff against the previous read of THIS specific URI to + // avoid cross-contamination when multiple memory windows are open. + const uriKey = uri.toString(); + const uriState = this.uriBytes.get(uriKey) ?? { current: [], previous: [] }; + const changedOffsets = new Set(this.computeChangedOffsets(bytes, uriState.current)); + uriState.previous = uriState.current; + uriState.current = bytes.slice(); + this.uriBytes.set(uriKey, uriState); + this.uriChangedOffsets.set(uriKey, changedOffsets); + + // Resolve per-URI display settings, falling back to global defaults. + const uriOverrides = this.uriSettings.get(uriKey) ?? {}; + const dataType = uriOverrides.dataType ?? this.dataType; + const endianness = uriOverrides.endianness ?? this.endianness; + const showAscii = uriOverrides.showAscii ?? this.showAscii; + let output = ''; - output += ' Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F \t\n'; + // Header with data type info + const asciiHeader = showAscii ? ' | ASCII' : ''; + output += ` Data Type: ${dataType}, Endianness: ${endianness}\n`; + output += ` Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F${asciiHeader}\n`; + + let rowAddress = address - (address % 16); + const offset = address - rowAddress; output += hexFormat(rowAddress, 8, false) + ': '; let asciiStr = ''; @@ -58,7 +227,9 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider : String.fromCharCode(bytes[i]); if ((address + i) % 16 === 15 && i < length - 1) { - output += ' ' + asciiStr; + if (showAscii) { + output += ' |' + asciiStr; + } asciiStr = ''; output += '\n'; rowAddress += 16; @@ -70,9 +241,26 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider for (let i = 0; i < remaining; i++) { output += ' '; } - output += ' ' + asciiStr; + if (showAscii) { + output += ' |' + asciiStr; + } output += '\n'; + // Add data type interpretation section + const typeInfo = this.formatDataTypeInterpretation(bytes, address, dataType, endianness); + if (typeInfo) { + output += '\n Data Type Interpretation:\n'; + output += typeInfo; + } + + // Add memory diff summary when bytes changed since last read + if (changedOffsets.size > 0) { + output += `\n Diff: ${changedOffsets.size} byte(s) changed since last read\n`; + } + + // Store the prior snapshot so getChangedOffsets() returns the true diff. + this.previousBytes = uriState.previous.slice(); + resolve(output); }, (error: any) => { @@ -85,6 +273,174 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider }); } + /** Formats data type interpretation of the bytes. */ + private formatDataTypeInterpretation( + bytes: number[], + baseAddress: number, + dataType: MemoryDataType, + endianness: Endianness + ): string { + if (bytes.length === 0) return ''; + + const typeSize = this.getTypeSize(dataType); + let output = ''; + let lineCount = 0; + const maxLines = 16; // Limit output lines + + for (let i = 0; i < bytes.length && lineCount < maxLines; i += typeSize) { + if (i + typeSize > bytes.length) break; + + const value = this.readValue(bytes, i, dataType, endianness); + const address = baseAddress + i; + + output += ` ${hexFormat(address, 8)}: `; + + switch (dataType) { + case MemoryDataType.U8: + case MemoryDataType.U16: + case MemoryDataType.U32: + output += `${value} (0x${value.toString(16).toUpperCase()})\n`; + break; + case MemoryDataType.U64: + output += `${value} (0x${(value as bigint).toString(16).toUpperCase()})\n`; + break; + case MemoryDataType.I8: + case MemoryDataType.I16: + case MemoryDataType.I32: + output += `${value}\n`; + break; + case MemoryDataType.I64: + output += `${value}\n`; + break; + case MemoryDataType.Float: + output += `${value}\n`; + break; + case MemoryDataType.Double: + output += `${value}\n`; + break; + } + lineCount++; + } + + return output; + } + + /** Gets the size of a data type in bytes. */ + private getTypeSize(type: MemoryDataType): number { + switch (type) { + case MemoryDataType.U8: + case MemoryDataType.I8: + return 1; + case MemoryDataType.U16: + case MemoryDataType.I16: + return 2; + case MemoryDataType.U32: + case MemoryDataType.I32: + case MemoryDataType.Float: + return 4; + case MemoryDataType.U64: + case MemoryDataType.I64: + case MemoryDataType.Double: + return 8; + default: + return 1; + } + } + + /** Reads a value from bytes at given offset with specified type and endianness. */ + private readValue(bytes: number[], offset: number, type: MemoryDataType, endianness: Endianness): number | bigint { + const size = this.getTypeSize(type); + const buf = Buffer.alloc(size); + + for (let i = 0; i < size; i++) { + buf[i] = bytes[offset + i]; + } + + if (endianness === Endianness.Big) { + switch (type) { + case MemoryDataType.U8: return buf.readUInt8(0); + case MemoryDataType.U16: return buf.readUInt16BE(0); + case MemoryDataType.U32: return buf.readUInt32BE(0); + case MemoryDataType.U64: return buf.readBigUInt64BE(0); + case MemoryDataType.I8: return buf.readInt8(0); + case MemoryDataType.I16: return buf.readInt16BE(0); + case MemoryDataType.I32: return buf.readInt32BE(0); + case MemoryDataType.I64: return buf.readBigInt64BE(0); + case MemoryDataType.Float: return buf.readFloatBE(0); + case MemoryDataType.Double: return buf.readDoubleBE(0); + default: return buf.readUInt8(0); + } + } + + switch (type) { + case MemoryDataType.U8: return buf.readUInt8(0); + case MemoryDataType.U16: return buf.readUInt16LE(0); + case MemoryDataType.U32: return buf.readUInt32LE(0); + case MemoryDataType.U64: return buf.readBigUInt64LE(0); + case MemoryDataType.I8: return buf.readInt8(0); + case MemoryDataType.I16: return buf.readInt16LE(0); + case MemoryDataType.I32: return buf.readInt32LE(0); + case MemoryDataType.I64: return buf.readBigInt64LE(0); + case MemoryDataType.Float: return buf.readFloatLE(0); + case MemoryDataType.Double: return buf.readDoubleLE(0); + default: return buf.readUInt8(0); + } + } + + /** Writes a single byte to memory at the given address. */ + async writeByte(address: number, value: number): Promise { + if (!vscode.debug.activeDebugSession) { + return false; + } + + if (!Number.isInteger(value) || value < 0 || value > 255) { + vscode.window.showErrorMessage( + `Invalid byte value ${value}: must be an integer in the range 0–255` + ); + return false; + } + + try { + const hexValue = hexFormat(value, 2, false); + await vscode.debug.activeDebugSession.customRequest('write-memory', { + address, + data: hexValue + }); + return true; + } catch (error) { + vscode.window.showErrorMessage(`Failed to write memory at ${hexFormat(address, 8)}: ${error}`); + return false; + } + } + + /** Writes multiple bytes to memory at the given address. */ + async writeBytes(address: number, bytes: number[]): Promise { + if (!vscode.debug.activeDebugSession) { + return false; + } + + for (const b of bytes) { + if (!Number.isInteger(b) || b < 0 || b > 255) { + vscode.window.showErrorMessage( + `Invalid byte value ${b}: each byte must be an integer in the range 0–255` + ); + return false; + } + } + + try { + const hexData = bytes.map(b => hexFormat(b, 2, false)).join(''); + await vscode.debug.activeDebugSession.customRequest('write-memory', { + address, + data: hexData + }); + return true; + } catch (error) { + vscode.window.showErrorMessage(`Failed to write memory at ${hexFormat(address, 8)}: ${error}`); + return false; + } + } + /** Triggers a content refresh. */ update(document: vscode.TextDocument): void { this._onDidChange.fire(document.uri); @@ -92,26 +448,39 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider /** Maps editor position to byte offset. */ getOffset(position: vscode.Position): number | undefined { - if (position.line < 1 || position.character < this.firstBytePos) { + if (position.line < this.headerLines) { + return undefined; + } + + if (position.character < this.firstBytePos) { return; } - let offset = 16 * (position.line - 1); - const charOffset = position.character - this.firstBytePos; - - if (position.character >= this.firstBytePos && position.character <= this.lastBytePos) { - offset += Math.floor(charOffset / 3); - } else if (position.character >= this.firstAsciiPos) { - offset += position.character - this.firstAsciiPos; + if (position.character > this.lastBytePos && position.character < this.firstAsciiPos) { + return; } - return offset; + let offset = 16 * Math.max(0, position.line - this.headerLines); + + if (position.character >= this.firstBytePos && position.character <= this.lastBytePos) { + const charOffset = position.character - this.firstBytePos; + offset += Math.floor(charOffset / 3); + return offset; + } + + if (position.character >= this.firstAsciiPos && position.character < this.lastAsciiPos) { + offset += position.character - this.firstAsciiPos; + return offset; + } + + return; } /** Maps byte offset to editor position. */ getPosition(offset: number, isAscii: boolean = false): vscode.Position { - const line = 1 + Math.floor(offset / 16); - let character = offset % 16; + const normalizedOffset = Math.max(0, offset); + const line = this.headerLines + Math.floor(normalizedOffset / 16); + let character = normalizedOffset % 16; if (isAscii) { character += this.firstAsciiPos; } else { @@ -139,6 +508,24 @@ export class MemoryContentProvider implements vscode.TextDocumentContentProvider return ranges; } + /** Applies diff decorations (highlighting changed bytes) to the given editor. */ + applyDiffDecorations(editor: vscode.TextEditor): void { + const uriKey = editor.document.uri.toString(); + const changedOffsets = this.uriChangedOffsets.get(uriKey); + if (!changedOffsets || changedOffsets.size === 0) { + editor.setDecorations(this.diffDecorationType, []); + return; + } + const ranges: vscode.Range[] = []; + for (const offset of changedOffsets) { + ranges.push(...this.getRanges(offset, offset, false)); + if (this.uriSettings.get(uriKey)?.showAscii ?? this.showAscii) { + ranges.push(...this.getRanges(offset, offset, true)); + } + } + editor.setDecorations(this.diffDecorationType, ranges); + } + /** Applies decorations for the selected range. */ handleSelection(event: vscode.TextEditorSelectionChangeEvent): void { const lineCount = event.textEditor.document.lineCount; diff --git a/src/frontend/peripheral.ts b/src/frontend/peripheral.ts index fee6eb4..a00afc6 100644 --- a/src/frontend/peripheral.ts +++ b/src/frontend/peripheral.ts @@ -1,4 +1,6 @@ import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import * as vscode from 'vscode'; import { XMLParser } from 'fast-xml-parser'; import { NumberFormat } from '../common'; @@ -52,6 +54,7 @@ export class BaseNode { public format: NumberFormat = NumberFormat.Auto; public description: string; public accessType?: AccessType; + private cachedTreeNode?: TreeNode; constructor(public recordType: RecordType) {} @@ -75,6 +78,31 @@ export class BaseNode { return null; } + protected getOrCreateTreeNode( + label: string, + collapsibleState: vscode.TreeItemCollapsibleState, + contextValue: string + ): TreeNode { + if (!this.cachedTreeNode) { + this.cachedTreeNode = new TreeNode(label, collapsibleState, contextValue, this); + } + + this.cachedTreeNode.label = label; + this.cachedTreeNode.collapsibleState = collapsibleState; + this.cachedTreeNode.contextValue = contextValue; + this.cachedTreeNode.node = this; + this.cachedTreeNode.command = { + command: 'platformio-debug.peripherals.selectedNode', + arguments: [this], + title: 'Selected Node', + }; + this.cachedTreeNode.tooltip = this.description || label; + this.cachedTreeNode.iconPath = undefined; + this.cachedTreeNode.description = undefined; + + return this.cachedTreeNode; + } + getCopyValue(): string | null { return null; } @@ -175,11 +203,10 @@ export class PeripheralNode extends BaseNode { getTreeNode(): TreeNode { const label = this.name + ' [' + hexFormat(this.baseAddress) + ']'; - return new TreeNode( + return this.getOrCreateTreeNode( label, this.expanded ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed, - 'peripheral', - this + 'peripheral' ); } @@ -292,11 +319,10 @@ export class ClusterNode extends BaseNode { getTreeNode(): TreeNode { const label = `${this.name} [${hexFormat(this.offset, 0)}]`; - return new TreeNode( + return this.getOrCreateTreeNode( label, this.expanded ? vscode.TreeItemCollapsibleState.Expanded : vscode.TreeItemCollapsibleState.Collapsed, - 'cluster', - this + 'cluster' ); } @@ -367,6 +393,8 @@ export class RegisterNode extends BaseNode { public size: number; public resetValue: bigint; public currentValue: bigint; + public previousValue: bigint; + public valueChanged: boolean = false; public hexLength: number; public maxValue: bigint; public binaryRegex: RegExp; @@ -382,6 +410,7 @@ export class RegisterNode extends BaseNode { this.size = options.size || parent.size; this.resetValue = options.resetValue !== undefined ? BigInt(options.resetValue) : (parent.resetValue ?? 0n); this.currentValue = this.resetValue; + this.previousValue = this.resetValue; this.hexLength = Math.ceil(this.size / 4); this.maxValue = 1n << BigInt(this.size); this.binaryRegex = new RegExp(`^0b[01]{1,${this.size}}$`, 'i'); @@ -443,7 +472,28 @@ export class RegisterNode extends BaseNode { : vscode.TreeItemCollapsibleState.Collapsed : vscode.TreeItemCollapsibleState.None; - return new TreeNode(label, collapsible, contextValue, this); + const treeNode = this.getOrCreateTreeNode(label, collapsible, contextValue); + + // Highlight registers whose value differs from the documented reset value. + // Recently changed registers get an additional indicator. + if (this.currentValue !== this.resetValue) { + const color = this.valueChanged + ? new vscode.ThemeColor('charts.orange') + : new vscode.ThemeColor('charts.yellow'); + treeNode.iconPath = new vscode.ThemeIcon('circle-filled', color); + const tooltipParts: string[] = []; + if (this.description) { + tooltipParts.push(this.description); + } + tooltipParts.push(`Reset: ${hexFormat(this.resetValue, this.hexLength)}`); + tooltipParts.push(`Current: ${hexFormat(this.currentValue, this.hexLength)}`); + if (this.valueChanged) { + tooltipParts.push(`Previous: ${hexFormat(this.previousValue, this.hexLength)}`); + } + treeNode.tooltip = tooltipParts.join('\n'); + } + + return treeNode; } getChildren(): BaseNode[] { @@ -549,6 +599,7 @@ export class RegisterNode extends BaseNode { } const buffer = Buffer.from(bytes); + const prior = this.currentValue; switch (byteCount) { case 1: this.currentValue = BigInt(buffer.readUInt8(0)); @@ -568,6 +619,10 @@ export class RegisterNode extends BaseNode { ); } + // Track value changes between successive reads to drive change highlighting. + this.valueChanged = this.currentValue !== prior; + this.previousValue = prior; + this.children.forEach((child) => child.update()); return Promise.resolve(true); } @@ -693,7 +748,35 @@ export class FieldNode extends BaseNode { contextValue = 'field-ro'; } - return new TreeNode(label, vscode.TreeItemCollapsibleState.None, contextValue, this); + const treeNode = this.getOrCreateTreeNode(label, vscode.TreeItemCollapsibleState.None, contextValue); + const ACCESS_TYPE_NAMES: { [key: number]: string } = { + [AccessType.ReadOnly]: 'Read-Only', + [AccessType.ReadWrite]: 'Read-Write', + [AccessType.WriteOnly]: 'Write-Only', + }; + + // Build enhanced tooltip with bit-field documentation + const tooltipParts: string[] = []; + if (this.description) { + tooltipParts.push(this.description); + } + tooltipParts.push(`Bits [${this.offset + this.width - 1}:${this.offset}], width: ${this.width}`); + tooltipParts.push(`Access: ${ACCESS_TYPE_NAMES[this.accessType] ?? this.accessType}`) + if (this.enumeration) { + tooltipParts.push('Values:'); + const sortedKeys = Object.keys(this.enumeration).sort((a, b) => { + const va = BigInt(a); + const vb = BigInt(b); + return va < vb ? -1 : va > vb ? 1 : 0; + }); + for (const key of sortedKeys) { + const e = this.enumeration[key] as EnumerationValue; + tooltipParts.push(` ${e.name} = ${key}: ${e.description}`); + } + } + treeNode.tooltip = tooltipParts.join('\n'); + + return treeNode; } performUpdate(): Promise { @@ -772,6 +855,22 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider private viewExpanded: boolean = false; private svdPath: string; private initialSettings: any[]; + private treeView: vscode.TreeView | undefined; + + /** Stores a reference to the TreeView so search can reveal entries. */ + setTreeView(treeView: vscode.TreeView): void { + this.treeView = treeView; + } + + /** Returns the currently configured SVD path (if any). */ + getSVDPath(): string | undefined { + return this.svdPath; + } + + /** Returns the loaded peripheral nodes (used for testing/search). */ + getPeripherals(): PeripheralNode[] { + return this.peripherials; + } /** Refreshes the tree view. */ refresh(): void { @@ -1098,6 +1197,158 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider return peripheral; } + /** + * Resolves transitive `` chains. + * Each derived peripheral inherits the base's properties (including + * registers/clusters) while keeping its own overrides such as name and + * baseAddress. + */ + private _resolvePeripheralDerivedFrom(peripheralMap: { [name: string]: any }): void { + const resolved: { [name: string]: boolean } = {}; + const resolve = (name: string, visiting: Set): void => { + if (resolved[name]) { + return; + } + if (visiting.has(name)) { + throw new Error(`Circular derivedFrom reference detected at peripheral ${name}`); + } + const periph = peripheralMap[name]; + const baseName: string | undefined = periph?.['@_derivedFrom']; + if (!baseName || !peripheralMap[baseName]) { + resolved[name] = true; + return; + } + visiting.add(name); + resolve(baseName, visiting); + visiting.delete(name); + peripheralMap[name] = this._mergePeripheralDefinitions(peripheralMap[baseName], periph); + // Drop the marker so we don't re-process if called again. + delete peripheralMap[name]['@_derivedFrom']; + resolved[name] = true; + }; + + for (const name in peripheralMap) { + resolve(name, new Set()); + } + } + + /** + * Deep-merges a derived peripheral on top of its base. + * Scalar properties are overridden by the derived value. + * The `registers` container is merged by name so that sibling registers/clusters + * from the base are preserved when the derived peripheral only overrides some. + */ + private _mergePeripheralDefinitions(base: any, derived: any): any { + const result: any = { ...base }; + for (const key of Object.keys(derived)) { + if (key === 'registers' && base[key] != null && derived[key] != null) { + result[key] = this._mergeRegistersContainer(base[key], derived[key]); + } else { + result[key] = derived[key]; + } + } + return result; + } + + /** + * Merges two SVD `` container objects by combining their + * `register` and `cluster` arrays, keyed by name. Derived entries override + * base entries with the same name; unique base entries are preserved. + */ + private _mergeRegistersContainer(base: any, derived: any): any { + const merged: any = { ...base }; + for (const key of ['register', 'cluster'] as const) { + if (derived[key] === undefined) { + continue; + } + const baseArr: any[] = ([] as any[]).concat(base[key] ?? []); + const derivedArr: any[] = ([] as any[]).concat(derived[key]); + const byName = new Map(baseArr.map((r) => [r.name, r])); + for (const item of derivedArr) { + const existing = byName.get(item.name); + byName.set(item.name, existing ? { ...existing, ...item } : item); + } + merged[key] = Array.from(byName.values()); + } + return merged; + } + + /** + * Resolves register, cluster and field `derivedFrom` references within a + * single peripheral. The lookup is by simple name and supports clusters + * deriving from clusters and registers deriving from registers. + */ + private _resolveInnerDerivedFrom(periph: any): void { + if (!periph?.registers) { + return; + } + + // In-place merge that mutates the stored object so that array entries + // also see the inherited properties. + const merge = (map: { [name: string]: any }, name: string, visiting: Set): void => { + const node = map[name]; + const baseName: string | undefined = node?.['@_derivedFrom']; + if (!baseName || !map[baseName]) { + return; + } + if (visiting.has(name)) { + throw new Error(`Circular derivedFrom reference detected at ${name}`); + } + visiting.add(name); + merge(map, baseName, visiting); + visiting.delete(name); + const merged = { ...map[baseName], ...node }; + delete merged['@_derivedFrom']; + // Mutate the existing object so array references stay consistent. + for (const key of Object.keys(node)) { + delete node[key]; + } + Object.assign(node, merged); + }; + + const resolveArray = (items: any[]): void => { + const map: { [name: string]: any } = {}; + for (const item of items) { + if (item?.name) { + map[item.name] = item; + } + } + for (const name in map) { + merge(map, name, new Set()); + } + }; + + const resolveFields = (registersList: any[]): void => { + for (const reg of registersList) { + const fields: any[] = reg?.fields?.field; + if (Array.isArray(fields)) { + resolveArray(fields); + } + } + }; + + const registers: any[] = Array.isArray(periph.registers.register) + ? periph.registers.register + : []; + const clusters: any[] = Array.isArray(periph.registers.cluster) + ? periph.registers.cluster + : []; + + resolveArray(registers); + resolveArray(clusters); + + // Resolve nested registers and fields within clusters. + for (const cluster of clusters) { + if (Array.isArray(cluster.register)) { + resolveArray(cluster.register); + resolveFields(cluster.register); + } + } + + // Resolve fields inside top-level registers. + resolveFields(registers); + } + /** Reads/parses SVD XML file. */ _loadSVD(svdPath: string): Promise { return new Promise((resolve, reject) => { @@ -1108,6 +1359,7 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider try { const parser = new XMLParser({ ignoreAttributes: false, + parseTagValue: false, isArray: (_name: string, jpath: string) => { const arrayPaths = [ 'device.peripherals.peripheral', @@ -1145,13 +1397,12 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider peripheralMap[name] = periph; }); - // Handle derived peripherals + // Handle derived peripherals (including transitive chains) + this._resolvePeripheralDerivedFrom(peripheralMap); + + // Resolve register/cluster/field derivedFrom within each peripheral for (const name in peripheralMap) { - const periph = peripheralMap[name]; - if (periph['@_derivedFrom']) { - const base = peripheralMap[periph['@_derivedFrom']]; - peripheralMap[name] = { ...base, ...periph }; - } + this._resolveInnerDerivedFrom(peripheralMap[name]); } this.peripherials = []; @@ -1280,6 +1531,118 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider this.initialSettings = savedState; } + /** + * Searches well-known locations for a `.svd` file. + * Order: + * 1. workspaceRoot/.vscode/*.svd + * 2. workspaceRoot/*.svd + * 3. ~/.platformio/packages/*\/svd/*.svd (e.g. framework-*, tool-openocd) + * If a `deviceName` is provided, candidates whose filename contains the + * device name (case-insensitive) are preferred. + */ + public findSVDFile(deviceName?: string): string | undefined { + const candidates: string[] = []; + + const collect = (dir: string): void => { + try { + if (!fs.existsSync(dir)) { + return; + } + const entries = fs.readdirSync(dir).sort((a, b) => a.localeCompare(b)); + for (const entry of entries) { + if (entry.toLowerCase().endsWith('.svd')) { + candidates.push(path.join(dir, entry)); + } + } + } catch { + // Ignore IO errors during discovery. + } + }; + + const folders = vscode.workspace.workspaceFolders || []; + for (const folder of folders) { + const root = folder.uri.fsPath; + collect(path.join(root, '.vscode')); + collect(root); + } + + const pioPackages = path.join(os.homedir(), '.platformio', 'packages'); + try { + if (fs.existsSync(pioPackages)) { + const pkgs = fs.readdirSync(pioPackages).sort((a, b) => a.localeCompare(b)); + for (const pkg of pkgs) { + collect(path.join(pioPackages, pkg, 'svd')); + } + } + } catch { + // Ignore IO errors during discovery. + } + + if (candidates.length === 0) { + return undefined; + } + + if (deviceName) { + const lower = deviceName.toLowerCase(); + const match = candidates.find((c) => + path.basename(c).toLowerCase().includes(lower) + ); + // When a device name is given but no filename matches, return undefined so + // callers can detect the missing device-specific SVD rather than silently + // loading the wrong file. + return match; + } + + return candidates[0]; + } + + /** + * Opens a QuickPick to filter peripherals by name/address/description and + * reveals the chosen peripheral in the tree view. + */ + public async search(): Promise { + if (this.peripherials.length === 0) { + vscode.window.showInformationMessage('No peripherals are currently loaded.'); + return; + } + + const items: vscode.QuickPickItem[] = this.peripherials.map((p) => ({ + label: p.name, + description: hexFormat(p.baseAddress), + detail: p.description, + })); + + const selected = await vscode.window.showQuickPick(items, { + placeHolder: 'Search peripherals by name, address, or description', + matchOnDescription: true, + matchOnDetail: true, + }); + + if (!selected) { + return; + } + + const peripheral = this.peripherials.find((p) => p.name === selected.label); + if (!peripheral) { + return; + } + + const treeNode = peripheral.getTreeNode(); + peripheral.expanded = true; + this.refresh(); + if (this.treeView) { + try { + await this.treeView.reveal(treeNode, { + select: true, + focus: true, + expand: true, + }); + } catch { + // reveal can fail if the node was just rebuilt; ignore. + } + } + } + /** Updates the SVD path and reloads the peripheral tree. */ reloadSVD(svdPath: string): void { this.peripherials = []; diff --git a/src/utils.ts b/src/utils.ts index dd3388f..9fd562d 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -86,21 +86,22 @@ export function extractBitsBigInt(value: bigint, offset: number, width: number): } /** - * Parses a string as a bigint, supporting hex (0x), binary (0b), decimal, and hash-binary (#) prefixes. + * Parses a value as a bigint, supporting hex (0x), binary (0b), decimal, and hash-binary (#) prefixes. + * Accepts strings, numbers, or bigints; other inputs are coerced via String(). */ -export function parseBigInt(value: string): bigint | undefined { - value = value.trim(); - if (/^0b([01]+)$/i.test(value)) { - return BigInt('0b' + value.substring(2)); +export function parseBigInt(value: unknown): bigint | undefined { + const str = String(value).trim(); + if (/^0b([01]+)$/i.test(str)) { + return BigInt('0b' + str.substring(2)); } - if (/^0x([0-9a-f]+)$/i.test(value)) { - return BigInt('0x' + value.substring(2)); + if (/^0x([0-9a-f]+)$/i.test(str)) { + return BigInt('0x' + str.substring(2)); } - if (/^[0-9]+$/i.test(value)) { - return BigInt(value); + if (/^[0-9]+$/i.test(str)) { + return BigInt(str); } - if (/^#[0-1]+$/i.test(value)) { - return BigInt('0b' + value.substring(1)); + if (/^#[0-1]+$/i.test(str)) { + return BigInt('0b' + str.substring(1)); } return undefined; } diff --git a/tsconfig.json b/tsconfig.json index 4926cba..405409e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,5 +10,5 @@ "skipLibCheck": true, "esModuleInterop": true }, - "exclude": ["node_modules", "dist", "dist_org", ".vscode-test", "__tests__"] + "exclude": ["node_modules", "dist", "dist_org", ".vscode-test", "__tests__", "__mocks__", "coverage"] }