Implement V120 (#11)

This commit is contained in:
Jason2866
2026-05-01 22:03:05 +02:00
committed by GitHub
parent 6deeea2031
commit 1cc3f84cf7
32 changed files with 6638 additions and 116 deletions
+639
View File
@@ -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 `<derivedFrom>` 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<void> {
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<RTOSType> {
// 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<ROSThread[]> {
// 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 `<derivedFrom>` 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
- `<derivedFrom>`: 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
+171
View File
@@ -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<any> => {
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
}
@@ -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 <hexAddr> <data>`
* 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<number, any>();
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');
});
});
});
@@ -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' },
@@ -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: ~"<content>\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);
}
);
});
+173
View File
@@ -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);
});
});
+152
View File
@@ -0,0 +1,152 @@
import {
FreeRTOSThreadParser,
RTOSDetector,
RTOSManager,
RTOSType,
ThreadXThreadParser,
ZephyrThreadParser,
} from '../../src/backend/rtos'
function createReader(values: Record<string, any>) {
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' })
})
})
@@ -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')
})
})
@@ -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()
})
})
})
@@ -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<typeof getDiagnosticsManager>
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()
})
})
})
})
@@ -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')
})
})
@@ -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')
})
})
@@ -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)
})
})
})
+273
View File
@@ -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')
})
})
})
@@ -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 })
})
})
@@ -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()
})
})
+228
View File
@@ -0,0 +1,228 @@
/**
* Tests for SVD <derivedFrom> 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<string, any> = {
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<string, any> = {
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<string, any> = {
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<string, any> = {
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/)
})
})
@@ -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, '<device/>')
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, '<device/>')
fs.writeFileSync(rootSvd, '<device/>')
const provider = new PeripheralTreeProvider()
expect(provider.findSVDFile()).toBe(dotSvd)
})
test('discovers SVDs under ~/.platformio/packages/<pkg>/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, '<device/>')
const provider = new PeripheralTreeProvider()
expect(provider.findSVDFile()).toBe(svd)
})
test('prefers a candidate matching the device name', () => {
fs.writeFileSync(path.join(workspaceDir, 'random.svd'), '<device/>')
const target = path.join(workspaceDir, 'esp32.svd')
fs.writeFileSync(target, '<device/>')
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, '<device/>')
fs.writeFileSync(b, '<device/>')
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()
})
})
@@ -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<string, string | undefined>) {
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/)
})
})
+4
View File
@@ -9,6 +9,10 @@ module.exports = {
'!**/*.test.ts',
'!**/*.spec.ts',
'!**/*.d.ts',
'!__mocks__/**',
],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
moduleNameMapper: {
'^vscode$': '<rootDir>/__mocks__/vscode.ts',
},
};
+29 -16
View File
@@ -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": {
+1 -1
View File
@@ -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": {
+139 -45
View File
@@ -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<number, { threadId: number; frameLevel: number }> = new Map();
private nextFrameId: number = 256;
private fileExistsCache: Map<string, boolean> = new Map();
private rtosManager = new RTOSManager();
private rtosType: RTOSType = RTOSType.None;
private rtosThreadMap: Map<number, RTOSThread> = new Map();
private dapThreadIdMap: Map<number, number> = 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<void> {
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<void> {
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<void> {
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<void> {
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);
}
+1
View File
@@ -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);
+658
View File
@@ -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<any>;
}
export interface RTOSThreadParser {
readonly type: RTOSType;
parseThreads(reader: RTOSExpressionReader, options?: RTOSLoadOptions): Promise<RTOSThread[]>;
}
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<string | undefined> {
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<RTOSType> {
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<boolean> {
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<RTOSThread> {
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<number>,
threads: RTOSThread[],
maxTasks: number,
source: RTOSType
): Promise<void> {
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<RTOSThread[]> {
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<number>([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<RTOSThread[]> {
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<number>();
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<RTOSThread[]> {
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<number>();
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<RTOSType, RTOSThreadParser>();
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<RTOSLoadResult> {
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 };
}
}
+8 -4
View File
@@ -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.')
);
}
+215 -2
View File
@@ -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<string>('memory.defaultDataType', 'u8');
const defaultEndianness = cfg.get<string>('memory.defaultEndianness', 'little');
this.memoryContentProvider.setDataType(defaultDataType as MemoryDataType);
this.memoryContentProvider.setEndianness(defaultEndianness as Endianness);
this.diagnostics.setShowDevDebugOutput(cfg.get<boolean>('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<void> {
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<void> {
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<void> {
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}`);
}
}
/**
+496
View File
@@ -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
}
+403 -16
View File
@@ -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<vscode.Uri>();
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<string, { current: number[]; previous: number[] }>();
private readonly uriChangedOffsets = new Map<string, Set<number>>();
// Per-URI display settings; fall back to global defaults when absent.
private readonly uriSettings = new Map<string, { dataType?: MemoryDataType; endianness?: Endianness; showAscii?: boolean }>();
/** 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<string> {
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<boolean> {
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 0255`
);
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<boolean> {
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 0255`
);
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;
+377 -14
View File
@@ -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<boolean> {
@@ -772,6 +855,22 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider<TreeNode>
private viewExpanded: boolean = false;
private svdPath: string;
private initialSettings: any[];
private treeView: vscode.TreeView<TreeNode> | undefined;
/** Stores a reference to the TreeView so search can reveal entries. */
setTreeView(treeView: vscode.TreeView<TreeNode>): 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<TreeNode>
return peripheral;
}
/**
* Resolves transitive `<peripheral derivedFrom="...">` 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<string>): 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<string>());
}
}
/**
* 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 `<registers>` 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<string, any>(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<string>): 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<string>());
}
};
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<boolean> {
return new Promise((resolve, reject) => {
@@ -1108,6 +1359,7 @@ export class PeripheralTreeProvider implements vscode.TreeDataProvider<TreeNode>
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<TreeNode>
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<TreeNode>
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<void> {
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 = [];
+12 -11
View File
@@ -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;
}
+1 -1
View File
@@ -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"]
}