diff --git a/README.md b/README.md index 672cd1e..7c4ef0a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,21 @@ This extension provides intelligent formatting for Device Tree Source (DTS) file *Example of Device Tree file before and after formatting* +### Include File Navigation + +This extension provides intelligent navigation for `#include` directives with support for: + +- **Go to definition**: Ctrl+Click on include paths to jump to the file +- **Smart file resolution**: Automatically searches multiple locations: + - Relative to current file + - Workspace root + - `arch/*/boot/dts/` directories (Linux kernel structure) + - `arch/*/dts/` directories (U-Boot structure) + - Configurable custom search paths +- **Visual feedback**: Include paths are underlined when the file is found +- **Diagnostic warnings**: Missing include files are highlighted with warnings +- **Configurable search paths**: Add custom directories via `devicetree.includeSearchPaths` setting + ### Supported File Extensions - `.dts` - Device Tree Source files diff --git a/eslint.config.mjs b/eslint.config.mjs index 878fff3..ba4ade6 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -55,4 +55,10 @@ export default [{ "max-lines-per-function": ["warn", { max: 100, skipBlankLines: true, skipComments: true }], complexity: ["warn", 15], }, +}, { + // Test files are allowed to have long functions (test suites are naturally large) + files: ["**/*.test.ts"], + rules: { + "max-lines-per-function": "off", + }, }]; diff --git a/package.json b/package.json index 119784c..5d5765c 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,7 @@ "scripts": { "compile": "webpack", "compile-tests": "tsc -p . --outDir out", - "lint": "eslint src", + "lint": "eslint src --max-warnings 0", "package": "webpack --mode production --devtool hidden-source-map", "pretest": "npm run compile-tests && npm run compile && npm run lint", "test": "vscode-test", @@ -61,11 +61,6 @@ { "title": "DeviceTree Language", "properties": { - "devicetree.maxLineLength": { - "type": "number", - "default": 80, - "description": "Maximum line length for DeviceTree files. Lines longer than this will be wrapped." - }, "devicetree.diagnostics.enableWarnings": { "type": "boolean", "default": true, @@ -75,6 +70,22 @@ "type": "boolean", "default": true, "markdownDescription": "Include comments when calculating line length for warnings." + }, + "devicetree.includeSearchPaths": { + "type": "array", + "default": [ + "include", + "include/dt-bindings" + ], + "description": "Additional directories to search for included files (relative to workspace root).", + "items": { + "type": "string" + } + }, + "devicetree.maxLineLength": { + "type": "number", + "default": 80, + "description": "Maximum line length for DeviceTree files. Lines longer than this will be wrapped." } } } diff --git a/src/diagnostics.ts b/src/diagnostics.ts index 8364ccc..a77beb5 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -1,4 +1,6 @@ import * as vscode from 'vscode'; +import * as path from 'path'; +import { DtsDocumentLinkProvider, parseIncludes } from './links'; /** * Provider for DeviceTree diagnostic warnings @@ -7,13 +9,15 @@ import * as vscode from 'vscode'; export class DtsDiagnosticsProvider { private diagnosticCollection: vscode.DiagnosticCollection; private maxLineLength: number; - private includeComments: boolean; private tabSize: number; + private includeComments: boolean; + private linkProvider: DtsDocumentLinkProvider; - constructor(maxLineLength: number, includeComments: boolean) { + constructor(maxLineLength: number, includeComments: boolean, linkProvider: DtsDocumentLinkProvider) { this.diagnosticCollection = vscode.languages.createDiagnosticCollection('devicetree'); this.maxLineLength = maxLineLength + 1; this.includeComments = includeComments; + this.linkProvider = linkProvider; // Read tabSize from editor configuration const editorConfig = vscode.workspace.getConfiguration('editor'); @@ -125,11 +129,51 @@ export class DtsDiagnosticsProvider { return diagnostics; } + /** + * Check for missing include files + * @param document The document to check + * @returns Array of diagnostics for missing include files + */ + private async checkIncludeFiles( + document: vscode.TextDocument, + ): Promise { + const diagnostics: vscode.Diagnostic[] = []; + const includes = parseIncludes(document); + const currentFileDir = path.dirname(document.uri.fsPath); + + for (const include of includes) { + // Check if file exists + const targetUri = await this.linkProvider.findIncludedFile( + include.path, + currentFileDir + ); + + if (!targetUri) { + const range = new vscode.Range( + new vscode.Position(include.line, include.startChar), + new vscode.Position(include.line, include.endChar) + ); + const diagnostic = new vscode.Diagnostic( + range, + `Include file "${include.path}" was not found.\n` + + `Update the 'includeSearchPaths' settings for new location to search.`, + vscode.DiagnosticSeverity.Warning + ); + diagnostic.source = 'DeviceTree'; + diagnostics.push(diagnostic); + } + } + + return diagnostics; + } + /** * Analyze a document and update diagnostics * @param document The document to analyze */ - public analyzeDocument(document: vscode.TextDocument): void { + public async analyzeDocument( + document: vscode.TextDocument + ): Promise { if (document.languageId !== 'dts') { return; } @@ -143,11 +187,16 @@ export class DtsDiagnosticsProvider { this.tabSize = editor.options.tabSize as number; } + // Run all diagnostic checks const diagnostics: vscode.Diagnostic[] = []; - // Run all diagnostic checks, currently only line length + // Check line length diagnostics.push(...this.checkLineLength(document)); + // Check include files + const includeDiagnostics = await this.checkIncludeFiles(document); + diagnostics.push(...includeDiagnostics); + this.diagnosticCollection.set(document.uri, diagnostics); } diff --git a/src/extension.ts b/src/extension.ts index 013f872..654191f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,10 +4,12 @@ // Import the module and reference it with the alias vscode in your code below import * as vscode from 'vscode'; // Import the formatter and diagnostics providers -import { DtsFormatterProvider } from './formatter'; +import { DtsDocumentLinkProvider } from './links'; import { DtsDiagnosticsProvider } from './diagnostics'; +import { DtsFormatterProvider } from './formatter'; // Global provider instances +let linkProvider: DtsDocumentLinkProvider | undefined; let diagnosticsProvider: DtsDiagnosticsProvider | undefined; let formatterProvider: DtsFormatterProvider | undefined; @@ -23,11 +25,17 @@ export function activate(context: vscode.ExtensionContext) { const maxLineLength = config.get('maxLineLength', 80); const enableWarnings = config.get('diagnostics.enableWarnings', true); const includeComments = config.get('diagnostics.lineLengthIncludeComments', true); + const includeSearchPaths = config.get('includeSearchPaths', ['include', 'include/dt-bindings']); // Create providers - diagnosticsProvider = new DtsDiagnosticsProvider(maxLineLength, includeComments); + linkProvider = new DtsDocumentLinkProvider(includeSearchPaths); + diagnosticsProvider = new DtsDiagnosticsProvider(maxLineLength, includeComments, linkProvider); formatterProvider = new DtsFormatterProvider(maxLineLength); + context.subscriptions.push( + vscode.languages.registerDocumentLinkProvider('dts', linkProvider) + ); + context.subscriptions.push( vscode.languages.registerDocumentFormattingEditProvider('dts', formatterProvider) ); @@ -78,12 +86,18 @@ export function activate(context: vscode.ExtensionContext) { const config = vscode.workspace.getConfiguration('devicetree'); const maxLineLength = config.get('maxLineLength', 80); const includeComments = config.get('diagnostics.lineLengthIncludeComments', true); + const includeSearchPaths = config.get('includeSearchPaths', ['include', 'include/dt-bindings']); // Update formatter settings if (formatterProvider) { formatterProvider.updateSettings(maxLineLength); } + // Update link provider search paths + if (linkProvider) { + linkProvider.updateSearchPaths(includeSearchPaths); + } + // Update diagnostics settings if (diagnosticsProvider) { diagnosticsProvider.updateSettings(maxLineLength, includeComments); @@ -114,4 +128,7 @@ export function deactivate() { diagnosticsProvider.dispose(); diagnosticsProvider = undefined; } + if (linkProvider) { + linkProvider = undefined; + } } diff --git a/src/links.ts b/src/links.ts new file mode 100644 index 0000000..44b5b95 --- /dev/null +++ b/src/links.ts @@ -0,0 +1,234 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; + +/** + * Represents a parsed #include directive + */ +export interface IncludeInfo { + path: string; + line: number; + startChar: number; + endChar: number; +} + +/** + * Parse all #include directives from a document + * @param document The document to parse + * @returns Array of parsed include directives + */ +export function parseIncludes(document: vscode.TextDocument): IncludeInfo[] { + const includes: IncludeInfo[] = []; + const includeRegex = /#include\s+[<"]([^>"]+)[>"]/; + const text = document.getText() + .replace(/\/\*[\s\S]*?\*\//g, m => m.replace(/[^\n]/g, ' ')) + .replace(/\/\/.*/g, m => ' '.repeat(m.length)); + + const lines = text.split('\n'); + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const includeMatch = line.match(includeRegex); + + if (includeMatch) { + const includePath = includeMatch[1]; + const startIndex = line.indexOf(includeMatch[1]); + const endIndex = startIndex + includeMatch[1].length; + + includes.push({ + path: includePath, + line: i, + startChar: startIndex, + endChar: endIndex + }); + } + } + + return includes; +} + +/** + * Provider for DeviceTree #include directive links + * Shows underlined clickable links for include paths that exist + */ +export class DtsDocumentLinkProvider implements vscode.DocumentLinkProvider { + private includeSearchPaths: string[]; + + constructor(includeSearchPaths: string[]) { + this.includeSearchPaths = includeSearchPaths; + } + + /** + * Update configuration settings + * @param includeSearchPaths The new include search paths + */ + updateSearchPaths(includeSearchPaths: string[]): void { + this.includeSearchPaths = includeSearchPaths; + } + + /** + * Search in arch directories for Linux kernel and U-Boot patterns + * Linux: arch/'*'/boot/dts/, U-Boot: arch/'*'/dts/ + * @param workspaceRoot The workspace root path + * @param includePath The include path to search for + * @returns The URI of the found file or null + */ + private searchInArchDirectories( + workspaceRoot: string, + includePath: string + ): vscode.Uri | null { + const archDir = path.join(workspaceRoot, 'arch'); + + if (!fs.existsSync(archDir)) { + return null; + } + + // Get all architecture directories + const archTypes = fs.readdirSync(archDir, { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name); + + // Search patterns: Linux kernel and U-Boot + const patterns = [ + ['boot', 'dts'], // Linux kernel: arch/*/boot/dts/ + ['dts'] // U-Boot: arch/*/dts/ + ]; + + for (const arch of archTypes) { + for (const pattern of patterns) { + const dtsPath = path.join(archDir, arch, ...pattern, includePath); + if (fs.existsSync(dtsPath)) { + return vscode.Uri.file(dtsPath); + } + } + } + + return null; + } + + /** + * Search in configured custom paths + * @param workspaceRoot The workspace root path + * @param includePath The include path to search for + * @param searchPaths Custom search paths + * @returns The URI of the found file or null + */ + private searchInCustomPaths( + workspaceRoot: string, + includePath: string, + searchPaths: string[] + ): vscode.Uri | null { + for (const searchPath of searchPaths) { + const fullPath = path.join(workspaceRoot, searchPath, includePath); + if (fs.existsSync(fullPath)) { + return vscode.Uri.file(fullPath); + } + } + return null; + } + + /** + * Find the included file in the workspace (public for diagnostics) + * @param includePath The path from the include directive + * @param currentFileDir The directory of the current file + * @returns The URI of the found file or null + */ + public async findIncludedFile( + includePath: string, + currentFileDir: string + ): Promise { + // First, try relative to current file + const relativeToCurrentFile = path.join(currentFileDir, includePath); + if (fs.existsSync(relativeToCurrentFile)) { + return vscode.Uri.file(relativeToCurrentFile); + } + + // Get workspace folders + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return null; + } + + // Get configured search paths from class member + const customSearchPaths = this.includeSearchPaths; + + for (const folder of workspaceFolders) { + const workspaceRoot = folder.uri.fsPath; + + // Try relative to workspace root + const workspaceRelative = path.join(workspaceRoot, includePath); + if (fs.existsSync(workspaceRelative)) { + return vscode.Uri.file(workspaceRelative); + } + + // Search in arch/*/boot/dts/ directories (Linux kernel pattern) + const archResult = this.searchInArchDirectories(workspaceRoot, includePath); + if (archResult) { + return archResult; + } + + // Search in configured custom paths + if (customSearchPaths.length > 0) { + const customResult = this.searchInCustomPaths( + workspaceRoot, + includePath, + customSearchPaths + ); + if (customResult) { + return customResult; + } + } + + // Fallback: search recursively in workspace + const searchPattern = `**/${path.basename(includePath)}`; + const files = await vscode.workspace.findFiles( + searchPattern, + '{**/.*,**/node_modules/**}', + 10 // Limit results + ); + + // Filter to exact path match + for (const file of files) { + if (file.fsPath.endsWith(includePath)) { + return file; + } + } + } + + return null; + } + + /** + * Provide document links for all #include directives + * @param document The document to provide links for + * @returns Array of document links + */ + async provideDocumentLinks( + document: vscode.TextDocument + ): Promise { + const includes = parseIncludes(document); + const links: vscode.DocumentLink[] = []; + const currentFileDir = path.dirname(document.uri.fsPath); + + for (const include of includes) { + // Try to find the included file + const targetUri = await this.findIncludedFile( + include.path, + currentFileDir + ); + + // Only create a link if the file exists + if (targetUri) { + const range = new vscode.Range( + new vscode.Position(include.line, include.startChar), + new vscode.Position(include.line, include.endChar) + ); + const link = new vscode.DocumentLink(range, targetUri); + link.tooltip = targetUri.fsPath; + links.push(link); + } + } + + return links; + } +} diff --git a/src/test/links.test.ts b/src/test/links.test.ts new file mode 100644 index 0000000..e012916 --- /dev/null +++ b/src/test/links.test.ts @@ -0,0 +1,278 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { DtsDocumentLinkProvider, parseIncludes } from '../links'; + +// Shared setup for all definition tests +suiteSetup(async () => { + const extension = vscode.extensions.getExtension('andy9a9.vscode-devicetree'); + if (extension && !extension.isActive) { + await extension.activate(); + } +}); + +suite('DTS Include Parser', () => { + test('Should parse include with double quotes', async () => { + const content = '#include "test.dtsi"\n/ {\n};'; + const doc = await vscode.workspace.openTextDocument({ + language: 'dts', + content: content + }); + + const includes = parseIncludes(doc); + assert.strictEqual(includes.length, 1); + assert.strictEqual(includes[0].path, 'test.dtsi'); + assert.strictEqual(includes[0].line, 0); + }); + + test('Should parse include with angle brackets', async () => { + const content = '#include \n/ {\n};'; + const doc = await vscode.workspace.openTextDocument({ + language: 'dts', + content: content + }); + + const includes = parseIncludes(doc); + assert.strictEqual(includes.length, 1); + assert.strictEqual(includes[0].path, 'dt-bindings/gpio/gpio.h'); + assert.strictEqual(includes[0].line, 0); + }); + + test('Should parse multiple includes', async () => { + const content = '#include "file1.dtsi"\n#include \n/ {\n};'; + const doc = await vscode.workspace.openTextDocument({ + language: 'dts', + content: content + }); + + const includes = parseIncludes(doc); + assert.strictEqual(includes.length, 2); + assert.strictEqual(includes[0].path, 'file1.dtsi'); + assert.strictEqual(includes[0].line, 0); + assert.strictEqual(includes[1].path, 'file2.dtsi'); + assert.strictEqual(includes[1].line, 1); + }); + + test('Should calculate correct character positions', async () => { + const content = '#include "test.dtsi"\n'; + const doc = await vscode.workspace.openTextDocument({ + language: 'dts', + content: content + }); + + const includes = parseIncludes(doc); + assert.strictEqual(includes.length, 1); + assert.strictEqual(includes[0].startChar, 10); // After #include " + assert.strictEqual(includes[0].endChar, 19); // End position of "test.dtsi" + }); + + test('Should ignore non-include lines', async () => { + const content = '/ {\n\tmodel = "test";\n\t#address-cells = <1>;\n};'; + const doc = await vscode.workspace.openTextDocument({ + language: 'dts', + content: content + }); + + const includes = parseIncludes(doc); + assert.strictEqual(includes.length, 0); + }); + + test('Should ignore include commented out with //', async () => { + const content = '//#include "input-tabs.dts"\n/ {\n};'; + const doc = await vscode.workspace.openTextDocument({ + language: 'dts', + content: content + }); + + const includes = parseIncludes(doc); + assert.strictEqual(includes.length, 0); + }); + + test('Should ignore include commented out with // and space', async () => { + const content = '// #include "input-tabs.dts"\n/ {\n};'; + const doc = await vscode.workspace.openTextDocument({ + language: 'dts', + content: content + }); + + const includes = parseIncludes(doc); + assert.strictEqual(includes.length, 0); + }); + + test('Should ignore include inside block comment', async () => { + const content = '/*\n#include "input-xxx.dts"\n*/\n/ {\n};'; + const doc = await vscode.workspace.openTextDocument({ + language: 'dts', + content: content + }); + + const includes = parseIncludes(doc); + assert.strictEqual(includes.length, 0); + }); + + test('Should ignore include on same line as /* comment */', async () => { + const content = '/* #include "input-xxx.dts" */\n/ {\n};'; + const doc = await vscode.workspace.openTextDocument({ + language: 'dts', + content: content + }); + + const includes = parseIncludes(doc); + assert.strictEqual(includes.length, 0); + }); + + test('Should parse active include while ignoring commented ones', async () => { + const content = '#include "active.dtsi"\n//#include "commented.dtsi"\n/*\n#include "block-commented.dtsi"\n*/\n/ {\n};'; + const doc = await vscode.workspace.openTextDocument({ + language: 'dts', + content: content + }); + + const includes = parseIncludes(doc); + assert.strictEqual(includes.length, 1); + assert.strictEqual(includes[0].path, 'active.dtsi'); + }); + + test('Should handle includes with paths containing slashes', async () => { + const content = '#include "vendor/board/board.dtsi"\n'; + const doc = await vscode.workspace.openTextDocument({ + language: 'dts', + content: content + }); + + const includes = parseIncludes(doc); + assert.strictEqual(includes.length, 1); + assert.strictEqual(includes[0].path, 'vendor/board/board.dtsi'); + }); +}); + +suite('DTS DocumentLink Provider - File Resolution', () => { + test('Should find file relative to current directory', async () => { + // Create a temporary workspace structure + const tempDir = path.join(__dirname, '..', '..', 'test-workspace-resolve'); + + try { + fs.mkdirSync(tempDir, { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'test.dtsi'), '/ {};'); + fs.writeFileSync(path.join(tempDir, 'main.dts'), '#include "test.dtsi"\n/ {};'); + + const provider = new DtsDocumentLinkProvider([]); + const result = await provider.findIncludedFile('test.dtsi', tempDir); + + assert.ok(result, 'Should find file in current directory'); + assert.ok(result.fsPath.endsWith('test.dtsi')); + } finally { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + }); + + test('Should find file with path relative to current directory', async () => { + const tempDir = path.join(__dirname, '..', '..', 'test-workspace-subdir'); + + try { + fs.mkdirSync(path.join(tempDir, 'subdir'), { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'subdir', 'test.dtsi'), '/ {};'); + fs.writeFileSync(path.join(tempDir, 'main.dts'), '#include "subdir/test.dtsi"\n/ {};'); + + const provider = new DtsDocumentLinkProvider([]); + const result = await provider.findIncludedFile('subdir/test.dtsi', tempDir); + + assert.ok(result, 'Should find file in subdirectory'); + assert.ok(result.fsPath.includes(path.join('subdir', 'test.dtsi'))); + } finally { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + }); + + test('Should return null for non-existent file', async () => { + const tempDir = path.join(__dirname, '..', '..', 'test-workspace-nonexist'); + + try { + fs.mkdirSync(tempDir, { recursive: true }); + + const provider = new DtsDocumentLinkProvider([]); + const result = await provider.findIncludedFile('nonexistent.dtsi', tempDir); + + assert.strictEqual(result, null); + } finally { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + }); +}); + +suite('DTS Diagnostics - Include File Warnings', () => { + let tempDir: string; + + suiteSetup(() => { + tempDir = path.join(__dirname, '..', '..', 'test-workspace-diag'); + + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + + fs.mkdirSync(tempDir, { recursive: true }); + fs.writeFileSync(path.join(tempDir, 'existing.dtsi'), '/ {};'); + }); + + suiteTeardown(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + test('Should warn for missing include file', async () => { + const content = '#include "missing.dtsi"\n/ {\n};'; + const mainFile = path.join(tempDir, 'test-missing.dts'); + fs.writeFileSync(mainFile, content); + + const doc = await vscode.workspace.openTextDocument(mainFile); + + // Wait for diagnostics + await new Promise(resolve => setTimeout(resolve, 600)); + + const diagnostics = vscode.languages.getDiagnostics(doc.uri); + const includeDiag = diagnostics.find(d => d.message.includes('was not found')); + + assert.ok(includeDiag, 'Should have diagnostic for missing include'); + assert.strictEqual(includeDiag.severity, vscode.DiagnosticSeverity.Warning); + }); + + test('Should not warn for existing include file', async () => { + const content = '#include "existing.dtsi"\n/ {\n};'; + const mainFile = path.join(tempDir, 'test-exists.dts'); + fs.writeFileSync(mainFile, content); + + const doc = await vscode.workspace.openTextDocument(mainFile); + + // Wait for diagnostics + await new Promise(resolve => setTimeout(resolve, 600)); + + const diagnostics = vscode.languages.getDiagnostics(doc.uri); + const includeDiag = diagnostics.find(d => d.message.includes('was not found')); + + assert.strictEqual(includeDiag, undefined, 'Should not have diagnostic for existing include'); + }); + + test('Should warn for multiple missing includes', async () => { + const content = '#include "missing1.dtsi"\n#include "missing2.dtsi"\n/ {\n};'; + const mainFile = path.join(tempDir, 'test-multiple.dts'); + fs.writeFileSync(mainFile, content); + + const doc = await vscode.workspace.openTextDocument(mainFile); + + // Wait for diagnostics + await new Promise(resolve => setTimeout(resolve, 600)); + + const diagnostics = vscode.languages.getDiagnostics(doc.uri); + const includeDiags = diagnostics.filter(d => d.message.includes('was not found')); + + assert.strictEqual(includeDiags.length, 2, 'Should have warnings for both missing includes'); + }); +});