diff --git a/packages/klipper/src/app/klipper-state.ts b/packages/klipper/src/app/klipper-state.ts new file mode 100644 index 0000000..32a563d --- /dev/null +++ b/packages/klipper/src/app/klipper-state.ts @@ -0,0 +1,242 @@ +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs/promises'; + +export interface Definition { + name: string; + type: 'gcode_macro' | 'delayed_gcode'; + uri: vscode.Uri; + line: number; + startCol: number; + endCol: number; +} + +export function stripComments(line: string): string { + let inDoubleQuote = false; + let inSingleQuote = false; + for (let i = 0; i < line.length; i++) { + const char = line[i]; + if (char === '"' && (i === 0 || line[i - 1] !== '\\')) { + inDoubleQuote = !inDoubleQuote; + } else if (char === "'" && (i === 0 || line[i - 1] !== '\\')) { + inSingleQuote = !inSingleQuote; + } else if ((char === '#' || char === ';') && !inDoubleQuote && !inSingleQuote) { + return line.substring(0, i); + } + } + return line; +} + +function splitGlobPath(basePath: string, relPath: string): { baseDir: string; pattern: string } { + const wildcardIndex = relPath.search(/[*?]/); + if (wildcardIndex === -1) { + return { baseDir: path.resolve(basePath, relPath), pattern: '' }; + } + + const beforeWildcard = relPath.substring(0, wildcardIndex); + const lastSlash = beforeWildcard.lastIndexOf('/'); + const lastBackslash = beforeWildcard.lastIndexOf('\\'); + const slashIndex = Math.max(lastSlash, lastBackslash); + + if (slashIndex === -1) { + return { baseDir: basePath, pattern: relPath }; + } + + const dirPart = relPath.substring(0, slashIndex); + const patternPart = relPath.substring(slashIndex + 1); + + return { + baseDir: path.resolve(basePath, dirPart), + pattern: patternPart + }; +} + +async function resolveIncludePath( + currentFileUri: vscode.Uri, + includePath: string, + workspaceFolder?: vscode.WorkspaceFolder +): Promise { + const cleanPath = includePath.replace(/\\/g, '/'); + let baseDir = path.dirname(currentFileUri.fsPath); + let relPath = cleanPath; + + if (relPath.includes('*') || relPath.includes('?')) { + const { baseDir: resolvedBaseDir, pattern } = splitGlobPath(baseDir, relPath); + const relativePattern = new vscode.RelativePattern(vscode.Uri.file(resolvedBaseDir), pattern); + try { + return await vscode.workspace.findFiles(relativePattern); + } catch (e) { + console.error(`Error finding files for include pattern: ${relPath}`, e); + return []; + } + } else { + const resolvedPath = path.resolve(baseDir, relPath); + return [vscode.Uri.file(resolvedPath)]; + } +} + +export async function parseFile( + uri: vscode.Uri, + workspaceFolder?: vscode.WorkspaceFolder +): Promise<{ includes: vscode.Uri[]; definitions: Definition[] }> { + const includes: vscode.Uri[] = []; + const definitions: Definition[] = []; + + let text = ''; + const openDoc = vscode.workspace.textDocuments.find(doc => doc.uri.fsPath === uri.fsPath); + if (openDoc) { + text = openDoc.getText(); + } else { + try { + const buffer = await fs.readFile(uri.fsPath); + text = buffer.toString('utf8'); + } catch (e) { + return { includes, definitions }; + } + } + + const lines = text.split(/\r?\n/); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const originalLine = lines[lineIndex]; + const stripped = stripComments(originalLine); + + // 1. Check for [include ...] + const includeMatch = stripped.match(/^\s*\[include\s+([^\]]+)\]/i); + if (includeMatch) { + const rawPath = includeMatch[1].trim(); + const resolved = await resolveIncludePath(uri, rawPath, workspaceFolder); + includes.push(...resolved); + continue; + } + + // 2. Check for [gcode_macro ...] + const macroMatch = stripped.match(/^\s*\[gcode_macro\s+([^\]]+)\]/i); + if (macroMatch) { + const name = macroMatch[1].trim(); + const startCol = originalLine.indexOf(name); + definitions.push({ + name, + type: 'gcode_macro', + uri, + line: lineIndex, + startCol: startCol !== -1 ? startCol : originalLine.indexOf('['), + endCol: startCol !== -1 ? startCol + name.length : originalLine.indexOf(']') + }); + continue; + } + + // 3. Check for [delayed_gcode ...] + const delayedMatch = stripped.match(/^\s*\[delayed_gcode\s+([^\]]+)\]/i); + if (delayedMatch) { + const name = delayedMatch[1].trim(); + const startCol = originalLine.indexOf(name); + definitions.push({ + name, + type: 'delayed_gcode', + uri, + line: lineIndex, + startCol: startCol !== -1 ? startCol : originalLine.indexOf('['), + endCol: startCol !== -1 ? startCol + name.length : originalLine.indexOf(']') + }); + continue; + } + } + + return { includes, definitions }; +} + +export class KlipperWorkspaceState { + // Map from file fsPath to resolved include URIs + public includes = new Map(); + // Map from file fsPath to list of definitions in that file + public definitions = new Map(); + + private disposables: vscode.Disposable[] = []; + + constructor() {} + + public async init() { + // 1. Scan initial workspace files + await this.scanWorkspace(); + + // 2. Set up document change listener for active editor updates + this.disposables.push( + vscode.workspace.onDidChangeTextDocument(async event => { + const ext = path.extname(event.document.uri.fsPath).toLowerCase(); + if (ext === '.cfg' || ext === '.gcode') { + await this.updateFile(event.document.uri); + } + }) + ); + + // 3. Set up file watchers for creation, changes, and deletions on disk + const watcher = vscode.workspace.createFileSystemWatcher('**/*.{cfg,gcode}'); + watcher.onDidCreate(async uri => await this.updateFile(uri)); + watcher.onDidChange(async uri => await this.updateFile(uri)); + watcher.onDidDelete(uri => this.removeFile(uri)); + this.disposables.push(watcher); + } + + public dispose() { + for (const d of this.disposables) { + d.dispose(); + } + this.disposables = []; + } + + private async scanWorkspace() { + const uris = await vscode.workspace.findFiles('**/*.{cfg,gcode}'); + // Parse files concurrently + await Promise.all(uris.map(uri => this.updateFile(uri))); + } + + public async updateFile(uri: vscode.Uri) { + const wsFolder = vscode.workspace.getWorkspaceFolder(uri); + const { includes, definitions } = await parseFile(uri, wsFolder); + const fsPath = uri.fsPath; + this.includes.set(fsPath, includes); + this.definitions.set(fsPath, definitions); + } + + public removeFile(uri: vscode.Uri) { + const fsPath = uri.fsPath; + this.includes.delete(fsPath); + this.definitions.delete(fsPath); + } + + /** + * Returns the undirected connected component of files connected to the startUri. + */ + public getConnectedFiles(startUri: vscode.Uri): Set { + const connected = new Set(); + const queue: string[] = [startUri.fsPath]; + connected.add(startUri.fsPath); + + // Build undirected adjacency map + const adj = new Map>(); + for (const [fromPath, toUris] of this.includes.entries()) { + for (const toUri of toUris) { + const toPath = toUri.fsPath; + if (!adj.has(fromPath)) adj.set(fromPath, new Set()); + if (!adj.has(toPath)) adj.set(toPath, new Set()); + adj.get(fromPath)!.add(toPath); + adj.get(toPath)!.add(fromPath); + } + } + + while (queue.length > 0) { + const current = queue.shift()!; + const neighbors = adj.get(current); + if (neighbors) { + for (const neighbor of neighbors) { + if (!connected.has(neighbor)) { + connected.add(neighbor); + queue.push(neighbor); + } + } + } + } + + return connected; + } +} diff --git a/packages/klipper/src/app/main.ts b/packages/klipper/src/app/main.ts index df4342c..1d55bc7 100644 --- a/packages/klipper/src/app/main.ts +++ b/packages/klipper/src/app/main.ts @@ -1,5 +1,35 @@ -import { ExtensionContext } from "vscode"; +import * as vscode from 'vscode'; +import { KlipperWorkspaceState } from './klipper-state'; +import { KlipperDefinitionProvider, KlipperReferenceProvider, KlipperRenameProvider } from './providers'; -export function activate(context: ExtensionContext) {} +let state: KlipperWorkspaceState | undefined; -export function deactivate() {} +export async function activate(context: vscode.ExtensionContext) { + state = new KlipperWorkspaceState(); + await state.init(); + context.subscriptions.push(state); + + const selector: vscode.DocumentSelector = [ + { language: 'klipper-cfg', scheme: 'file' }, + { language: 'klipper-gcode', scheme: 'file' } + ]; + + context.subscriptions.push( + vscode.languages.registerDefinitionProvider(selector, new KlipperDefinitionProvider(state)) + ); + + context.subscriptions.push( + vscode.languages.registerReferenceProvider(selector, new KlipperReferenceProvider(state)) + ); + + context.subscriptions.push( + vscode.languages.registerRenameProvider(selector, new KlipperRenameProvider(state)) + ); +} + +export function deactivate() { + if (state) { + state.dispose(); + state = undefined; + } +} diff --git a/packages/klipper/src/app/providers.ts b/packages/klipper/src/app/providers.ts new file mode 100644 index 0000000..27d42c5 --- /dev/null +++ b/packages/klipper/src/app/providers.ts @@ -0,0 +1,242 @@ +import * as vscode from 'vscode'; +import { KlipperWorkspaceState, stripComments } from './klipper-state'; +import * as fs from 'fs/promises'; + +export function isValidReference( + line: string, + startCol: number, + word: string, + type: 'gcode_macro' | 'delayed_gcode' +): boolean { + const before = line.substring(0, startCol); + const after = line.substring(startCol + word.length); + + // 1. Section Header Definition check + if (/^\s*\[\s*(gcode_macro|delayed_gcode)\s+$/i.test(before) && /^\s*\]/.test(after)) { + if (type === 'gcode_macro') { + return /^\s*\[\s*gcode_macro\s+$/i.test(before); + } else { + return /^\s*\[\s*delayed_gcode\s+$/i.test(before); + } + } + // Any other section header is not a valid reference + if (/^\s*\[\s*[^\]]+$/i.test(before) && /^\s*\]/.test(after)) { + return false; + } + + // 2. Built-in macro-referencing commands + // ID=word (only for delayed_gcode, inside UPDATE_DELAYED_GCODE) + if (type === 'delayed_gcode') { + if (/UPDATE_DELAYED_GCODE\s+[^#;]*\bID\s*=\s*["']?$/i.test(before)) { + return true; + } + } + + // MACRO=word (only for gcode_macro, inside SET_GCODE_VARIABLE) + if (type === 'gcode_macro') { + if (/SET_GCODE_VARIABLE\s+[^#;]*\bMACRO\s*=\s*["']?$/i.test(before)) { + return true; + } + } + + // COMMAND=word (only for gcode_macro, inside TUNING_TOWER) + if (type === 'gcode_macro') { + if (/TUNING_TOWER\s+[^#;]*\bCOMMAND\s*=\s*["']?$/i.test(before)) { + return true; + } + } + + // 3. Standalone G-code command calls + // Must not be followed by "=" (e.g. parameter definition) + if (/^\s*=/.test(after)) { + return false; + } + // Must not be preceded by "PARAM=" + if (/\b[a-zA-Z0-9_]+\s*=\s*["']?$/i.test(before)) { + return false; + } + + return true; +} + +export class KlipperDefinitionProvider implements vscode.DefinitionProvider { + constructor(private state: KlipperWorkspaceState) {} + + public provideDefinition( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken + ): vscode.ProviderResult { + const range = document.getWordRangeAtPosition(position); + if (!range) return null; + + const word = document.getText(range); + const connected = this.state.getConnectedFiles(document.uri); + const matches: vscode.Location[] = []; + + for (const fsPath of connected) { + const defs = this.state.definitions.get(fsPath); + if (defs) { + for (const def of defs) { + if (def.name.toLowerCase() === word.toLowerCase()) { + matches.push(new vscode.Location( + def.uri, + new vscode.Range(def.line, def.startCol, def.line, def.endCol) + )); + } + } + } + } + + return matches; + } +} + +export async function findWordReferences( + word: string, + type: 'gcode_macro' | 'delayed_gcode', + startUri: vscode.Uri, + state: KlipperWorkspaceState +): Promise { + const connected = state.getConnectedFiles(startUri); + const locations: vscode.Location[] = []; + const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`\\b${escaped}\\b`, 'gi'); + + for (const fsPath of connected) { + const uri = vscode.Uri.file(fsPath); + let text = ''; + + const openDoc = vscode.workspace.textDocuments.find(doc => doc.uri.fsPath === fsPath); + if (openDoc) { + text = openDoc.getText(); + } else { + try { + const buffer = await fs.readFile(fsPath); + text = buffer.toString('utf8'); + } catch (e) { + continue; + } + } + + const lines = text.split(/\r?\n/); + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const originalLine = lines[lineIndex]; + const stripped = stripComments(originalLine); + + for (const match of stripped.matchAll(regex)) { + const startCol = match.index!; + if (!isValidReference(stripped, startCol, word, type)) { + continue; + } + const endCol = startCol + word.length; + locations.push(new vscode.Location( + uri, + new vscode.Range(lineIndex, startCol, lineIndex, endCol) + )); + } + } + } + + return locations; +} + +export class KlipperReferenceProvider implements vscode.ReferenceProvider { + constructor(private state: KlipperWorkspaceState) {} + + public async provideReferences( + document: vscode.TextDocument, + position: vscode.Position, + context: vscode.ReferenceContext, + token: vscode.CancellationToken + ): Promise { + const range = document.getWordRangeAtPosition(position); + if (!range) return []; + + const word = document.getText(range); + const connected = this.state.getConnectedFiles(document.uri); + let targetType: 'gcode_macro' | 'delayed_gcode' | undefined; + + for (const fsPath of connected) { + const defs = this.state.definitions.get(fsPath); + if (defs) { + const found = defs.find(def => def.name.toLowerCase() === word.toLowerCase()); + if (found) { + targetType = found.type; + break; + } + } + } + + if (!targetType) return []; + + return await findWordReferences(word, targetType, document.uri, this.state); + } +} + +export class KlipperRenameProvider implements vscode.RenameProvider { + constructor(private state: KlipperWorkspaceState) {} + + public prepareRename( + document: vscode.TextDocument, + position: vscode.Position, + token: vscode.CancellationToken + ): vscode.ProviderResult { + const range = document.getWordRangeAtPosition(position); + if (!range) return null; + + const word = document.getText(range); + const connected = this.state.getConnectedFiles(document.uri); + let isDefined = false; + + for (const fsPath of connected) { + const defs = this.state.definitions.get(fsPath); + if (defs && defs.some(def => def.name.toLowerCase() === word.toLowerCase())) { + isDefined = true; + break; + } + } + + if (!isDefined) { + throw new Error("Only gcode_macro and delayed_gcode objects can be renamed."); + } + + return range; + } + + public async provideRenameEdits( + document: vscode.TextDocument, + position: vscode.Position, + newName: string, + token: vscode.CancellationToken + ): Promise { + const range = document.getWordRangeAtPosition(position); + if (!range) return null; + + const word = document.getText(range); + const connected = this.state.getConnectedFiles(document.uri); + let targetType: 'gcode_macro' | 'delayed_gcode' | undefined; + + for (const fsPath of connected) { + const defs = this.state.definitions.get(fsPath); + if (defs) { + const found = defs.find(def => def.name.toLowerCase() === word.toLowerCase()); + if (found) { + targetType = found.type; + break; + } + } + } + + if (!targetType) return null; + + const references = await findWordReferences(word, targetType, document.uri, this.state); + const edit = new vscode.WorkspaceEdit(); + + for (const ref of references) { + edit.replace(ref.uri, ref.range, newName); + } + + return edit; + } +}