From ba836ee30f1812f753d97cdba65378bbdf6e289a Mon Sep 17 00:00:00 2001 From: kuro Date: Mon, 12 Oct 2026 21:43:46 +0200 Subject: [PATCH] =?UTF-8?q?erkennung=20f=C3=BCr=20fehlende=20end=20blocks,?= =?UTF-8?q?=20mehrzeilige=20if/for/while=20usw=20erkennung=20um=20keine=20?= =?UTF-8?q?semikolon=20fehler=20zu=20melden,=20besseres=20auslesen=20impor?= =?UTF-8?q?t=20defines,=20infos=20werden=20jetzt=20auch=20im=20Problems=20?= =?UTF-8?q?fenster=20gezeigt,=20bessere=20autovervollst=C3=A4ndigung,=20au?= =?UTF-8?q?tomatische=20fehlersuche=20=C3=BCber=20das=20projekt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 6 + src/core/LinterContext.ts | 1 + src/core/rules/BlockRule.ts | 38 ++++- src/core/rules/SyntaxRule.ts | 87 +++++++--- src/extension.ts | 31 +++- src/providers/TplCompletionProvider.ts | 9 +- src/providers/TplHoverProvider.ts | 100 +++++++----- src/providers/actions/MetadataFix.ts | 6 +- .../completions/CompletionStrategy.ts | 7 +- .../completions/LocalContextCompletion.ts | 103 +++++++++++- .../completions/SnippetCompletion.ts | 149 +++++++++--------- src/test/suite/actions.test.ts | 34 ++++ src/test/suite/completions.test.ts | 38 +++++ src/test/suite/linter.test.ts | 66 +++++--- 14 files changed, 481 insertions(+), 194 deletions(-) create mode 100644 src/test/suite/actions.test.ts create mode 100644 src/test/suite/completions.test.ts diff --git a/package.json b/package.json index 3bcc217..c16d3e2 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,13 @@ "default": 23, "description": "Aktuelle TPL-Nebenversion (z. B. 23 für TPL 1.23). Wird genutzt um veraltete Module zu erkennen und beim Generieren eingefügt." } + }, + "commands": [ + { + "command": "tpl.scanWorkspace", + "title": "TPL: Gesamten Workspace prüfen" } + ] }, "languages": [ { diff --git a/src/core/LinterContext.ts b/src/core/LinterContext.ts index e7cb340..e5008df 100644 --- a/src/core/LinterContext.ts +++ b/src/core/LinterContext.ts @@ -35,6 +35,7 @@ export class LinterContext { public parenStack: number[] = []; public bracketStack: number[] = []; public openDefines: number[] = []; + public openControlBlocks: { type: string, lineIdx: number }[] = []; public addDiag(lineIdx: number, start: number, end: number, msg: string, severity: vscode.DiagnosticSeverity): vscode.Diagnostic { const diag = new vscode.Diagnostic(new vscode.Range(lineIdx, start, lineIdx, end), msg, severity); diff --git a/src/core/rules/BlockRule.ts b/src/core/rules/BlockRule.ts index 3799c7a..b1041a2 100644 --- a/src/core/rules/BlockRule.ts +++ b/src/core/rules/BlockRule.ts @@ -4,7 +4,6 @@ import { LinterContext, PatternPhase } from '../LinterContext'; export class BlockRule implements LinterRule { - // HIER GEFIXT: 'lines: string[]' wurde zur Signatur hinzugefügt! public checkLine(line: string, rawLine: string, lineIdx: number, ctx: LinterContext, lines: string[]): void { if (!ctx.hasModuleDef) { if (line.startsWith('tpl ')) { ctx.hasModuleDef = true; } @@ -30,9 +29,11 @@ export class BlockRule implements LinterRule { } this.handleBlockOpenings(line, rawLine, lineIdx, ctx); + + // NEU: if, for und while tracken + this.handleControlBlocks(line, rawLine, lineIdx, ctx); if (ctx.inBlock === 'pattern') { - // HIER GEFIXT: 'lines' wird jetzt korrekt an die Methode weitergereicht this.checkPatternPhases(line, rawLine, lineIdx, ctx, lines); } @@ -43,6 +44,38 @@ export class BlockRule implements LinterRule { for (const unclosedLine of ctx.openDefines) { ctx.addDiag(unclosedLine, 0, lines[unclosedLine].length, "Block 'define' wurde nicht mit 'end define;' geschlossen!", vscode.DiagnosticSeverity.Error); } + // NEU: Offene if/for/while Blöcke am Dateiende ankreiden + for (const block of ctx.openControlBlocks) { + ctx.addDiag(block.lineIdx, 0, lines[block.lineIdx].length, `Block '${block.type}' wurde nicht mit 'end ${block.type};' geschlossen!`, vscode.DiagnosticSeverity.Error); + } + } + + private handleControlBlocks(line: string, rawLine: string, lineIdx: number, ctx: LinterContext) { + // Block wird geöffnet + if (/^if\b/.test(line)) { ctx.openControlBlocks.push({ type: 'if', lineIdx }); } + else if (/^for\b/.test(line)) { ctx.openControlBlocks.push({ type: 'for', lineIdx }); } + else if (/^while\b/.test(line)) { ctx.openControlBlocks.push({ type: 'while', lineIdx }); } + + // Block wird geschlossen + if (line.startsWith('end if')) { this.closeControlBlock('if', line, lineIdx, ctx); } + else if (line.startsWith('end for')) { this.closeControlBlock('for', line, lineIdx, ctx); } + else if (line.startsWith('end while')) { this.closeControlBlock('while', line, lineIdx, ctx); } + } + + private closeControlBlock(expectedType: string, line: string, lineIdx: number, ctx: LinterContext) { + if (ctx.openControlBlocks.length === 0) { + ctx.addDiag(lineIdx, 0, line.length, `'end ${expectedType}' gefunden, ohne offenen Block.`, vscode.DiagnosticSeverity.Error); + return; + } + + // Wir nehmen den zuletzt geöffneten Block vom Stapel + const lastBlock = ctx.openControlBlocks.pop()!; + + if (lastBlock.type !== expectedType) { + ctx.addDiag(lineIdx, 0, line.length, `Falsches Ende. Erwartet: 'end ${lastBlock.type};', gefunden: 'end ${expectedType};'.`, vscode.DiagnosticSeverity.Error); + // Damit Folgefehler vermieden werden, legen wir ihn zurück + ctx.openControlBlocks.push(lastBlock); + } } private trackModuleMetadata(line: string, lineIdx: number, ctx: LinterContext) { @@ -117,7 +150,6 @@ export class BlockRule implements LinterRule { ctx.activePhaseName = "body"; ctx.activePhaseLine = lineIdx; - // DER LOG-CHECK IST ZURÜCK! let foundLog = false; for (let j = lineIdx + 1; j < lines.length; j++) { const aheadLine = lines[j].trim(); diff --git a/src/core/rules/SyntaxRule.ts b/src/core/rules/SyntaxRule.ts index 050faf0..388c734 100644 --- a/src/core/rules/SyntaxRule.ts +++ b/src/core/rules/SyntaxRule.ts @@ -4,11 +4,47 @@ import { LinterContext } from '../LinterContext'; export class SyntaxRule implements LinterRule { - public checkLine(line: string, rawLine: string, lineIdx: number, ctx: LinterContext): void { + public checkLine(line: string, rawLine: string, lineIdx: number, ctx: LinterContext, lines: string[]): void { this.checkTabs(rawLine, lineIdx, ctx); - this.checkBrackets(line, rawLine, lineIdx, ctx); - this.checkAssignments(rawLine, lineIdx, ctx); - this.checkSemicolons(line, rawLine, lineIdx, ctx); + + // TODOs finden (blauer Info-Hinweis) + const todoMatch = rawLine.match(/\bTODO\s*:(.*)/i); + if (todoMatch) { + ctx.addDiag(lineIdx, todoMatch.index!, rawLine.length, `TODO: ${todoMatch[1].trim()}`, vscode.DiagnosticSeverity.Information); + } + + let cleanLine = line.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, '""'); + const commentIdx = cleanLine.indexOf('//'); + let codePart = commentIdx !== -1 ? cleanLine.substring(0, commentIdx).trim() : cleanLine.trim(); + + if (codePart.length === 0) return; + + let isStructuralKeyword = /^(tpl|pattern|syncmapping|table|definitions?|define|enumeration|configuration|metadata|overview|constants|triggers|mapping|removal|body|end|on|tags|origin|author|products|publishers|tree_path|import|from)\b/.test(codePart); + + if (codePart.startsWith('"') || codePart.startsWith("'")) { + isStructuralKeyword = true; + } + + const isValidExecutionArea = + ctx.activePhaseName === 'body' || + ctx.activePhaseName === 'triggers' || + ctx.activePhaseName === 'constants' || + ctx.activePhaseName === 'overview' || + ctx.activePhaseName === 'mapping' || + ctx.activePhaseName === 'metadata' || + ctx.inModuleMetadata || + ctx.inBlock === 'table' || + ctx.inBlock === 'definitions' || + ctx.openDefines.length > 0; + + // 'then' und 'do' auf alleinstehenden Zeilen sind legitimer Code + if (!isStructuralKeyword && !isValidExecutionArea && !/^(then|do)\b/.test(codePart)) { + ctx.addDiag(lineIdx, 0, rawLine.length, "Ausführbarer Code ist hier nicht erlaubt. (Erlaubt in: 'body', 'triggers', 'constants', 'overview', 'mapping', 'metadata', 'definitions' oder 'define').", vscode.DiagnosticSeverity.Error); + } + + this.checkBrackets(codePart, rawLine, lineIdx, ctx); + this.checkAssignments(codePart, rawLine, lineIdx, ctx); + this.checkSemicolons(codePart, rawLine, lineIdx, ctx, line); } public checkEnd(lines: string[], ctx: LinterContext): void { @@ -24,13 +60,9 @@ export class SyntaxRule implements LinterRule { } } - private checkBrackets(line: string, rawLine: string, lineIdx: number, ctx: LinterContext) { - let cleanLine = line.replace(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, ''); - const commentIdx = cleanLine.indexOf('//'); - if (commentIdx !== -1) { cleanLine = cleanLine.substring(0, commentIdx); } - - for (let charIdx = 0; charIdx < cleanLine.length; charIdx++) { - const char = cleanLine[charIdx]; + private checkBrackets(codePart: string, rawLine: string, lineIdx: number, ctx: LinterContext) { + for (let charIdx = 0; charIdx < codePart.length; charIdx++) { + const char = codePart[charIdx]; if (char === '(') { ctx.parenStack.push(lineIdx); } else if (char === ')') { if (ctx.parenStack.length > 0) { ctx.parenStack.pop(); } @@ -44,35 +76,38 @@ export class SyntaxRule implements LinterRule { } } - private checkAssignments(rawLine: string, lineIdx: number, ctx: LinterContext) { - const lineWithoutStrings = rawLine.replace(/(["'])(?:(?=(\\?))\2.)*?\1/g, '""'); - - if (/\b(if|elif|where)\b/.test(lineWithoutStrings)) { ctx.inConditionBlock = true; } + private checkAssignments(codePart: string, rawLine: string, lineIdx: number, ctx: LinterContext) { + if (/\b(if|elif|where)\b/.test(codePart)) { ctx.inConditionBlock = true; } - if (lineWithoutStrings.includes('=') && !lineWithoutStrings.includes(':=') && !lineWithoutStrings.includes('>=') && !lineWithoutStrings.includes('<=')) { - if (!ctx.inConditionBlock && ctx.parenStack.length === 0 && ctx.bracketStack.length === 0 && !/\b(matches)\b/.test(lineWithoutStrings)) { - const idx = rawLine.indexOf('='); + if (codePart.includes('=') && !codePart.includes(':=') && !codePart.includes('>=') && !codePart.includes('<=')) { + if (!ctx.inConditionBlock && ctx.parenStack.length === 0 && ctx.bracketStack.length === 0 && !/\b(matches)\b/.test(codePart)) { + const idx = rawLine.indexOf('='); if (idx !== -1) { ctx.addDiag(lineIdx, idx, idx + 1, "Falsche Zuweisung. In TPL wird ':=' für Zuweisungen genutzt.", vscode.DiagnosticSeverity.Error); } } } - if (/\b(then|do)\b/.test(lineWithoutStrings) || lineWithoutStrings.endsWith(';')) { ctx.inConditionBlock = false; } + if (/\b(then|do)\b/.test(codePart) || codePart.endsWith(';')) { ctx.inConditionBlock = false; } } - private checkSemicolons(line: string, rawLine: string, lineIdx: number, ctx: LinterContext) { + private checkSemicolons(codePart: string, rawLine: string, lineIdx: number, ctx: LinterContext, originalTrimmedLine: string) { if (ctx.isClosingString) { - if (ctx.stringNeedsSemicolon && !line.endsWith(';')) { + if (ctx.stringNeedsSemicolon && !originalTrimmedLine.endsWith(';')) { ctx.addDiag(lineIdx, Math.max(0, rawLine.length - 1), rawLine.length, "Fehlendes Semikolon am Ende der Zuweisung.", vscode.DiagnosticSeverity.Error); } } else { - const noSemicolonNeeded = /^(pattern|syncmapping|traverse|table|enumeration|configuration|definitions?|define|metadata|overview|constants|triggers|mapping|removal|body|end\s+|if\s+|else|elif\s+|for\s+|while\s+|\/\/|\/\*|\*)/; - const continuesOnNextLine = /(,|and|or|not|\+|-|\*|\/|=|:=|matches)$/i.test(line); + // "then" und "do" dürfen am Anfang einer Zeile stehen, ohne ein Semikolon zu erfordern + const noSemicolonNeeded = /^(pattern|syncmapping|traverse|table|enumeration|configuration|definitions?|define|metadata|overview|constants|triggers|mapping|removal|body|end\s+|if\s+|else|elif\s+|for\s+|while\s+|then\b|do\b|\/\/|\/\*|\*)/.test(codePart); - if (!noSemicolonNeeded.test(line) && !ctx.justOpenedString && ctx.parenStack.length === 0 && ctx.bracketStack.length === 0 && ctx.inBlock !== 'enumeration' && !ctx.inConditionBlock && !continuesOnNextLine) { - const lineToCheck = line.split('//')[0].trim(); - if (!lineToCheck.endsWith(';') && !lineToCheck.endsWith('{') && !lineToCheck.endsWith(',')) { + // "in" (für for-Schleifen) als gültigen Zeilenumbruch hinzugefügt + const continuesOnNextLine = /(,|and|or|not|\+|-|\*|\/|=|:=|matches|in)$/i.test(codePart); + + // HIER GEFIXT: Wenn die Zeile auf 'then' oder 'do' endet, verlangt sie kein Semikolon! + const endsWithBlockOpener = /\b(then|do)$/.test(codePart); + + if (!noSemicolonNeeded && !ctx.justOpenedString && ctx.parenStack.length === 0 && ctx.bracketStack.length === 0 && ctx.inBlock !== 'enumeration' && !ctx.inConditionBlock && !continuesOnNextLine && !endsWithBlockOpener) { + if (codePart.length > 0 && !codePart.endsWith(';') && !codePart.endsWith('{') && !codePart.endsWith(',')) { ctx.addDiag(lineIdx, Math.max(0, rawLine.length - 1), rawLine.length, "Fehlendes Semikolon am Ende der Anweisung.", vscode.DiagnosticSeverity.Error); } } diff --git a/src/extension.ts b/src/extension.ts index 7e8a3cf..a6ec002 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -9,16 +9,14 @@ export function activate(context: vscode.ExtensionContext) { const diagnosticCollection = vscode.languages.createDiagnosticCollection('tpl-scanner'); context.subscriptions.push(diagnosticCollection); - // Linter-Events binden + // Linter-Events für geöffnete/geänderte Dateien binden vscode.workspace.onDidOpenTextDocument(doc => validateTplDocument(doc, diagnosticCollection)); vscode.workspace.onDidChangeTextDocument(event => validateTplDocument(event.document, diagnosticCollection)); - // Live-Reload für Einstellungen + // Live-Reload für Einstellungen (Scannt das gesamte Projekt neu) vscode.workspace.onDidChangeConfiguration(e => { if (e.affectsConfiguration('tpl')) { - for (const doc of vscode.workspace.textDocuments) { - validateTplDocument(doc, diagnosticCollection); - } + scanWorkspace(diagnosticCollection); } }); @@ -28,8 +26,25 @@ export function activate(context: vscode.ExtensionContext) { context.subscriptions.push(vscode.languages.registerCompletionItemProvider('tpl', new TplCompletionProvider(), '.')); context.subscriptions.push(vscode.languages.registerHoverProvider('tpl', new TplHoverProvider())); - // Bei Aktivierung offene Dokumente einmal scannen - for (const doc of vscode.workspace.textDocuments) { - validateTplDocument(doc, diagnosticCollection); + // NEU: Befehl registrieren, um manuell alle Dateien zu scannen + context.subscriptions.push(vscode.commands.registerCommand('tpl.scanWorkspace', () => { + scanWorkspace(diagnosticCollection); + vscode.window.showInformationMessage("TPL: Workspace-Scan gestartet."); + })); + + // NEU: Bei Aktivierung der Extension direkt einmal alles prüfen! + scanWorkspace(diagnosticCollection); +} + +// Die neue Engine für das projektweite Lesen +async function scanWorkspace(collection: vscode.DiagnosticCollection) { + // Sucht alle .tpl Dateien im gesamten geöffneten Projektordner + const files = await vscode.workspace.findFiles('**/*.tpl', '**/node_modules/**'); + + for (const file of files) { + // Öffnet die Datei "unsichtbar" im Hintergrund + const doc = await vscode.workspace.openTextDocument(file); + // Jagt sie durch unseren Linter + validateTplDocument(doc, collection); } } \ No newline at end of file diff --git a/src/providers/TplCompletionProvider.ts b/src/providers/TplCompletionProvider.ts index ad0cf90..ff49795 100644 --- a/src/providers/TplCompletionProvider.ts +++ b/src/providers/TplCompletionProvider.ts @@ -8,7 +8,6 @@ export class TplCompletionProvider implements vscode.CompletionItemProvider { private strategies: CompletionStrategy[]; constructor() { - // Hier registrieren wir alle Completion Engines this.strategies = [ new BuiltInCompletion(), new SnippetCompletion(), @@ -16,20 +15,18 @@ export class TplCompletionProvider implements vscode.CompletionItemProvider { ]; } - public provideCompletionItems( + public async provideCompletionItems( document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken, context: vscode.CompletionContext - ): vscode.ProviderResult { + ): Promise { - // Den Text in der aktuellen Zeile bis zum Cursor holen const linePrefix = document.lineAt(position).text.substring(0, position.character); const results: vscode.CompletionItem[] = []; - // Jede Engine um Vorschläge bitten for (const strategy of this.strategies) { - const items = strategy.provideCompletions(document, position, linePrefix); + const items = await strategy.provideCompletions(document, position, linePrefix); if (items && items.length > 0) { results.push(...items); } diff --git a/src/providers/TplHoverProvider.ts b/src/providers/TplHoverProvider.ts index 6daf930..7f85f18 100644 --- a/src/providers/TplHoverProvider.ts +++ b/src/providers/TplHoverProvider.ts @@ -1,51 +1,77 @@ import * as vscode from 'vscode'; -import { tplBuiltIns } from '../data/TplBuiltIns'; export class TplHoverProvider implements vscode.HoverProvider { - public async provideHover( - document: vscode.TextDocument, - position: vscode.Position, - token: vscode.CancellationToken - ): Promise { - - // Greift das Wort unter dem Mauszeiger ab, inklusive Punkte für Namespaces + public async provideHover(document: vscode.TextDocument, position: vscode.Position, token: vscode.CancellationToken): Promise { const range = document.getWordRangeAtPosition(position, /[a-zA-Z0-9_.]+/); - if (!range) { - return null; - } + if (!range) return null; + + const word = document.getText(range); // z.B. "modelling.testDefine" + const text = document.getText(); + const lines = text.split('\n'); - const word = document.getText(range); const parts = word.split('.'); + const importBase = parts[0]; + const funcName = parts.length > 1 ? parts[1] : word; - let namespace = ''; - let methodOrPatternName = ''; - - if (parts.length === 2) { - namespace = parts[0]; - methodOrPatternName = parts[1]; - } else if (parts.length === 1) { - methodOrPatternName = parts[0]; - } else { - return null; + // 1. Lokale Defines + if (parts.length === 1 || parts.length === 2) { + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const defMatch = line.match(new RegExp(`^\\s*define\\s+${funcName}\\b(?:\\s*\\((.*?)\\))?`)); + if (defMatch) { + const params = defMatch[1] ? defMatch[1].trim() : ''; + const md = new vscode.MarkdownString(); + md.appendCodeblock(`define ${funcName}(${params})`, 'tpl'); + + const textAfterDefine = text.substring(text.indexOf(line) + line.length); + const docMatch = textAfterDefine.match(/^\s*(?:"""|''')([\s\S]*?)(?:"""|''')/); + if (docMatch) { md.appendMarkdown(`\n\n---\n\n${docMatch[1].trim()}`); } + + return new vscode.Hover(md); + } + } } - // BMC Namespace-Methoden (z.B. discovery.listDirectory) - if (namespace && tplBuiltIns[namespace] && tplBuiltIns[namespace][methodOrPatternName]) { - const def = tplBuiltIns[namespace][methodOrPatternName]; - const markdown = new vscode.MarkdownString(); - markdown.appendCodeblock(`${namespace}.${methodOrPatternName}`, 'tpl'); - markdown.appendMarkdown(`---\n\n${def.description}`); - return new vscode.Hover(markdown); - } - // BMC Globale Funktionen (z.B. size, datatype) - else if (!namespace && tplBuiltIns['built_in_globals'] && tplBuiltIns['built_in_globals'][methodOrPatternName]) { - const def = tplBuiltIns['built_in_globals'][methodOrPatternName]; - const markdown = new vscode.MarkdownString(); - markdown.appendCodeblock(`${methodOrPatternName}`, 'tpl'); - markdown.appendMarkdown(`---\n\n${def.description}`); - return new vscode.Hover(markdown); + // 2. Cross-File Hover für from ... import + const fromImportRegex = new RegExp(`from\\s+([a-zA-Z0-9_.]+)\\s+import\\s+${importBase}\\b`); + const fromMatch = text.match(fromImportRegex); + if (fromMatch) { + const moduleName = fromMatch[1]; + + // Wenn der User über die Methode fährt (modelling.XYZ) + if (funcName !== importBase) { + const files = await vscode.workspace.findFiles('**/*.tpl', '**/node_modules/**'); + for (const file of files) { + const doc = await vscode.workspace.openTextDocument(file); + const docText = doc.getText(); + if (new RegExp(`tpl\\s+1\\.\\d+\\s+module\\s+${moduleName.replace(/\./g, '\\.')}\\s*;`).test(docText)) { + const docLines = docText.split('\n'); + for (let i = 0; i < docLines.length; i++) { + const line = docLines[i]; + const defMatch = line.match(new RegExp(`^\\s*define\\s+${funcName}\\b(?:\\s*\\((.*?)\\))?`)); + if (defMatch) { + const params = defMatch[1] ? defMatch[1].trim() : ''; + const md = new vscode.MarkdownString(); + md.appendCodeblock(`define ${funcName}(${params})`, 'tpl'); + + const textAfterDefine = docText.substring(docText.indexOf(line) + line.length); + const docMatch = textAfterDefine.match(/^\s*(?:"""|''')([\s\S]*?)(?:"""|''')/); + if (docMatch) { md.appendMarkdown(`\n\n---\n\n${docMatch[1].trim()}`); } + md.appendMarkdown(`\n\n*(Importiert aus \`${moduleName}\`)*`); + return new vscode.Hover(md); + } + } + } + } + } + // Wenn er nur über "modelling" fährt + return new vscode.Hover(new vscode.MarkdownString(`Importierte Definitions **${importBase}** aus Modul \`${moduleName}\`.`)); } + // 3. Hover für Built-Ins + if (word.startsWith('log.')) { return new vscode.Hover(new vscode.MarkdownString(`TPL Logging-Modul`)); } + if (word.startsWith('discovery.')) { return new vscode.Hover(new vscode.MarkdownString(`TPL Discovery-Modul`)); } + return null; } } \ No newline at end of file diff --git a/src/providers/actions/MetadataFix.ts b/src/providers/actions/MetadataFix.ts index c851e73..08b8046 100644 --- a/src/providers/actions/MetadataFix.ts +++ b/src/providers/actions/MetadataFix.ts @@ -8,14 +8,12 @@ export class MetadataFix implements TplQuickFix { const fix = new vscode.CodeAction("Fehlende Metadaten generieren", vscode.CodeActionKind.QuickFix); fix.edit = new vscode.WorkspaceEdit(); - // Einstellungen laden const config = vscode.workspace.getConfiguration('tpl'); const author = config.get('metadata.author', 'Kuro'); const origin = config.get('metadata.origin', 'Custom'); const publisher = config.get('metadata.publisher', 'IT-Operations'); const treePathBase = config.get('metadata.treePathBase', 'Grafing'); - // Herausfinden, welche Felder fehlen (steht im Diagnostic-Message-String) const missingFieldsStr = diagnostic.message.replace('Fehlende Pflicht-Metadaten (Modul-Ebene): ', ''); const missingFields = missingFieldsStr.split(', '); @@ -26,8 +24,8 @@ export class MetadataFix implements TplQuickFix { if (missingFields.includes('publishers')) insertText += ` publishers := '${publisher}';\n`; if (missingFields.includes('tree_path')) insertText += ` tree_path := '${treePathBase}', 'TODO_Category', 'TODO_Subcategory';\n`; - // Direkt über der Zeile 'end metadata;' einfügen - fix.edit.insert(document.uri, new vscode.Position(diagnostic.range.start.line, 0), insertText); + // HIER GEFIXT: Einfügen auf Zeile + 1, damit es IN den Block rutscht + fix.edit.insert(document.uri, new vscode.Position(diagnostic.range.start.line + 1, 0), insertText); fix.diagnostics = [diagnostic]; return [fix]; diff --git a/src/providers/completions/CompletionStrategy.ts b/src/providers/completions/CompletionStrategy.ts index a1a1b8b..6c8dd71 100644 --- a/src/providers/completions/CompletionStrategy.ts +++ b/src/providers/completions/CompletionStrategy.ts @@ -1,14 +1,9 @@ import * as vscode from 'vscode'; export interface CompletionStrategy { - /** - * @param document Das aktuelle Dokument - * @param position Die Cursor-Position - * @param linePrefix Der Text der aktuellen Zeile *vor* dem Cursor - */ provideCompletions( document: vscode.TextDocument, position: vscode.Position, linePrefix: string - ): vscode.CompletionItem[] | undefined; + ): vscode.ProviderResult; } \ No newline at end of file diff --git a/src/providers/completions/LocalContextCompletion.ts b/src/providers/completions/LocalContextCompletion.ts index 658abcd..157f435 100644 --- a/src/providers/completions/LocalContextCompletion.ts +++ b/src/providers/completions/LocalContextCompletion.ts @@ -2,12 +2,103 @@ import * as vscode from 'vscode'; import { CompletionStrategy } from './CompletionStrategy'; export class LocalContextCompletion implements CompletionStrategy { - - public provideCompletions(document: vscode.TextDocument, position: vscode.Position, linePrefix: string): vscode.CompletionItem[] | undefined { - // TODO für Phase 2: - // 1. Regex Suche im Dokument nach ":=" (Variablen tracken) - // 2. Projektweite Suche nach "define" Methoden für Autocomplete + public async provideCompletions(document: vscode.TextDocument, position: vscode.Position, linePrefix: string): Promise { + const text = document.getText(); + const items: vscode.CompletionItem[] = []; + + // 1. WENN DER USER NACH EINEM PUNKT TIPPT (Cross-File Lookup) + const dotMatch = linePrefix.match(/\b([a-zA-Z0-9_]+)\.$/); + if (dotMatch) { + const prefix = dotMatch[1]; // z.B. "modelling" + if (prefix === 'log' || prefix === 'discovery') return undefined; // Built-ins ignorieren + + // Prüfen, ob der Präfix importiert wurde + const importRegex = new RegExp(`from\\s+([a-zA-Z0-9_.]+)\\s+import\\s+${prefix}\\b`); + const match = importRegex.exec(text); + if (match) { + const moduleName = match[1]; // z.B. "Company.software_modelling" + const externalItems = await this.resolveImportedMethods(moduleName, prefix); + return externalItems; // Nur externe Methoden zurückgeben + } + return undefined; // Unbekannter Namespace + } - return undefined; + // 2. LOKALE DEFINITIONS & IMPORTS PARSEN + const lines = text.split('\n'); + let currentDefBlock = ""; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const defBlockMatch = line.match(/^\s*definitions?\s+([a-zA-Z0-9_]+)/); + if (defBlockMatch) { currentDefBlock = defBlockMatch[1]; continue; } + if (line.match(/^\s*end\s+definitions?/)) { currentDefBlock = ""; continue; } + + const defineMatch = line.match(/^\s*define\s+([a-zA-Z0-9_]+)\b/); + if (defineMatch) { + const funcName = defineMatch[1]; + const fullName = currentDefBlock ? `${currentDefBlock}.${funcName}` : funcName; + const item = new vscode.CompletionItem(fullName, vscode.CompletionItemKind.Function); + item.detail = currentDefBlock ? `Local Function in '${currentDefBlock}'` : `Local Function`; + item.insertText = new vscode.SnippetString(`${fullName}(\${1:params});$0`); + items.push(item); + } + } + + // Imports für die globale Worterkennung bereitstellen + const fromImportRegex = /from\s+([a-zA-Z0-9_.]+)\s+import\s+([a-zA-Z0-9_]+)/g; + let matchVar; + while ((matchVar = fromImportRegex.exec(text)) !== null) { + const item = new vscode.CompletionItem(matchVar[2], vscode.CompletionItemKind.Module); + item.detail = `Imported Definitions from ${matchVar[1]}`; + items.push(item); + } + + const varRegex = /([a-zA-Z0-9_]+)\s*:=/g; + const foundVars = new Set(); + while ((matchVar = varRegex.exec(text)) !== null) { + if (matchVar[1] !== 'host') { foundVars.add(matchVar[1]); } + } + for (const v of foundVars) { + items.push(new vscode.CompletionItem(v, vscode.CompletionItemKind.Variable)); + } + + return items; + } + + // Hilfsmethode: Sucht im Workspace nach der anderen Datei! + private async resolveImportedMethods(moduleName: string, defBlockName: string): Promise { + const items: vscode.CompletionItem[] = []; + // Sucht alle .tpl Dateien im Projekt + const files = await vscode.workspace.findFiles('**/*.tpl', '**/node_modules/**'); + + for (const file of files) { + const doc = await vscode.workspace.openTextDocument(file); + const text = doc.getText(); + + // Ist das das gesuchte Modul? (Punkte escapen) + const moduleRegex = new RegExp(`tpl\\s+1\\.\\d+\\s+module\\s+${moduleName.replace(/\./g, '\\.')}\\s*;`); + if (moduleRegex.test(text)) { + // Modul gefunden! Jetzt die Defines im entsprechenden Block auslesen + const lines = text.split('\n'); + let inTargetBlock = false; + for (const line of lines) { + if (line.match(new RegExp(`^\\s*definitions?\\s+${defBlockName}\\b`))) { inTargetBlock = true; continue; } + if (inTargetBlock && line.match(/^\s*end\s+definitions?/)) { inTargetBlock = false; break; } + + if (inTargetBlock) { + const defineMatch = line.match(/^\s*define\s+([a-zA-Z0-9_]+)\b/); + if (defineMatch) { + const funcName = defineMatch[1]; + const item = new vscode.CompletionItem(funcName, vscode.CompletionItemKind.Function); + item.detail = `Method from ${moduleName}`; + item.insertText = new vscode.SnippetString(`${funcName}(\${1:params});$0`); + items.push(item); + } + } + } + break; // Datei gefunden und geparst, wir können abbrechen + } + } + return items; } } \ No newline at end of file diff --git a/src/providers/completions/SnippetCompletion.ts b/src/providers/completions/SnippetCompletion.ts index 57069cb..ee6297c 100644 --- a/src/providers/completions/SnippetCompletion.ts +++ b/src/providers/completions/SnippetCompletion.ts @@ -4,96 +4,91 @@ import { CompletionStrategy } from './CompletionStrategy'; export class SnippetCompletion implements CompletionStrategy { public provideCompletions(document: vscode.TextDocument, position: vscode.Position, linePrefix: string): vscode.CompletionItem[] | undefined { - // Wenn wir gerade NACH einem Punkt tippen (z.B. "log."), sind Snippets/Base-Module hier falsch! - if (linePrefix.match(/\.[a-zA-Z0-9_]*$/)) { - return undefined; - } + if (linePrefix.match(/\.[a-zA-Z0-9_]*$/)) { return undefined; } const items: vscode.CompletionItem[] = []; const config = vscode.workspace.getConfiguration('tpl'); - // ========================================== - // 1. BASIS-NAMESPACES (discovery, log, etc.) - // ========================================== - const namespaces = ['discovery', 'log', 'model', 'search', 'text', 'regex', 'time', 'hash', 'number', 'inference']; - for (const ns of namespaces) { - const item = new vscode.CompletionItem(ns, vscode.CompletionItemKind.Module); - item.detail = `TPL Namespace: ${ns}`; - items.push(item); - } + // Kontext-Scanner: Wo befindet sich der Cursor gerade? + let inBlock: 'none' | 'pattern' | 'definitions' = 'none'; + let inPhase = ''; - // ========================================== - // 2. DAS GOD-SNIPPET (Skeleton) - // ========================================== - const skel = new vscode.CompletionItem('tpl_skeleton', vscode.CompletionItemKind.Snippet); - const version = config.get('version', 23); - const author = config.get('metadata.author', 'Kuro'); - - skel.insertText = new vscode.SnippetString( - `tpl 1.${version} module \${1:Name};\n\n` + - `metadata\n` + - ` origin := '\${2:Custom}';\n` + - ` author := '${author}';\n` + - ` tree_path := 'Grafing', '\${3:Category}', '\${4:SubCategory}';\n` + - `end metadata;\n\n` + - `pattern \${5:PatternName} 1.0\n` + - ` """\n \${6:Beschreibung}\n """\n` + - ` overview\n tags \${7:tag1};\n end overview;\n\n` + - ` triggers\n on host := Host created, confirmed;\n end triggers;\n\n` + - ` body\n log.debug("Start pattern \${5:PatternName}");\n $0\n end body;\n` + - `end pattern;` - ); - skel.detail = "Generiert ein komplettes TPL Grundgerüst"; - items.push(skel); - - // ========================================== - // 3. TPL HAUPT-BLÖCKE (pattern, define, etc.) - // ========================================== - const mainBlocks = [ - { label: 'pattern', desc: 'pattern ... end pattern;' }, - { label: 'syncmapping', desc: 'syncmapping ... end syncmapping;' }, - { label: 'define', desc: 'define ... end define;' } - ]; - - for (const block of mainBlocks) { - const item = new vscode.CompletionItem(block.label, vscode.CompletionItemKind.Snippet); - item.insertText = new vscode.SnippetString(`${block.label} \${1:Name} \${2:1.0}\n """\n \${3:DocString}\n """\n $0\nend ${block.label};`); - if (block.label === 'define') { - item.insertText = new vscode.SnippetString(`define \${1:methodName}(\${2:params})\n """\n \${3:DocString}\n """\n $0\nend define;`); + for (let i = 0; i <= position.line; i++) { + const text = document.lineAt(i).text.trim(); + if (text.match(/^(pattern|syncmapping)\b/)) { inBlock = 'pattern'; inPhase = ''; } + else if (text.match(/^definitions?\b/)) { inBlock = 'definitions'; inPhase = ''; } + else if (text.match(/^end (pattern|syncmapping|definitions?)\b/)) { inBlock = 'none'; inPhase = ''; } + + if (inBlock === 'pattern') { + ['metadata', 'overview', 'constants', 'triggers', 'mapping', 'removal', 'body'].forEach(p => { + if (text.startsWith(p)) { inPhase = p; } + else if (text.startsWith(`end ${p}`)) { inPhase = ''; } + }); } - item.detail = block.desc; + if (inBlock === 'definitions') { + if (text.startsWith('define ')) { inPhase = 'define'; } + else if (text.startsWith('end define')) { inPhase = ''; } + } + } + + // ========================================== + // 1. GLOBAL (Top-Level: Vor dem Pattern / Import-Bereich) + // ========================================== + if (inBlock === 'none') { + const skel = new vscode.CompletionItem('tpl_skeleton', vscode.CompletionItemKind.Snippet); + skel.insertText = new vscode.SnippetString(`tpl 1.23 module \${1:Name};\n\nmetadata\n origin := '\${2:Custom}';\n tree_path := 'Grafing', '\${3:Category}', '\${4:SubCategory}';\nend metadata;\n\npattern \${5:PatternName} 1.0\n """\n \${6:Beschreibung}\n """\n overview\n tags \${7:tag1};\n end overview;\n\n triggers\n on host := Host created, confirmed;\n end triggers;\n\n body\n log.debug("Start pattern \${5:PatternName}");\n $0\n end body;\nend pattern;`); + items.push(skel); + + ['pattern', 'syncmapping', 'definitions'].forEach(block => { + const item = new vscode.CompletionItem(block, vscode.CompletionItemKind.Snippet); + item.insertText = new vscode.SnippetString(`${block} \${1:Name} \${2:1.0}\n """\n \${3:DocString}\n """\n $0\nend ${block};`); + items.push(item); + }); + + const imp = new vscode.CompletionItem('import', vscode.CompletionItemKind.Snippet); + imp.insertText = new vscode.SnippetString('import ${1:module_name};'); + items.push(imp); + + const fromImp = new vscode.CompletionItem('from import', vscode.CompletionItemKind.Snippet); + fromImp.insertText = new vscode.SnippetString('from ${1:module_name} import${2:function_name};'); + items.push(fromImp); + } + + // ========================================== + // 2. INNERHALB PATTERN (aber VOR dem Body) + // ========================================== + if (inBlock === 'pattern' && inPhase === '') { + ['metadata', 'overview', 'constants', 'triggers', 'mapping', 'removal', 'body'].forEach(sec => { + const item = new vscode.CompletionItem(sec, vscode.CompletionItemKind.Snippet); + item.insertText = new vscode.SnippetString(`${sec}\n $0\nend ${sec};`); + items.push(item); + }); + } + + if (inBlock === 'definitions' && inPhase === '') { + const item = new vscode.CompletionItem('define', vscode.CompletionItemKind.Snippet); + item.insertText = new vscode.SnippetString(`define \${1:methodName}(\${2:params})\n """\n \${3:DocString}\n """\n $0\nend define;`); items.push(item); } // ========================================== - // 4. SECTIONS (metadata, body, triggers, etc.) + // 3. INNERHALB VON BODY ODER DEFINE (Ausführbarer Code) // ========================================== - const sections = ['metadata', 'overview', 'constants', 'triggers', 'mapping', 'removal', 'body']; - for (const sec of sections) { - const item = new vscode.CompletionItem(sec, vscode.CompletionItemKind.Snippet); - item.insertText = new vscode.SnippetString(`${sec}\n $0\nend ${sec};`); - item.detail = `${sec} ... end ${sec};`; - items.push(item); + if (inPhase === 'body' || inPhase === 'define') { + ['discovery', 'log', 'model', 'search', 'text', 'regex', 'time', 'hash', 'number', 'inference'].forEach(ns => { + const item = new vscode.CompletionItem(ns, vscode.CompletionItemKind.Module); + items.push(item); + }); + + const ifSnippet = new vscode.CompletionItem('if', vscode.CompletionItemKind.Snippet); + ifSnippet.insertText = new vscode.SnippetString('if ${1:condition} then\n $0\nend if;'); + items.push(ifSnippet); + + const forSnippet = new vscode.CompletionItem('for', vscode.CompletionItemKind.Snippet); + forSnippet.insertText = new vscode.SnippetString('for ${1:item} in${2:list} do\n $0\nend for;'); + items.push(forSnippet); } - // ========================================== - // 5. KONTROLLSTRUKTUREN & TABELLEN - // ========================================== - const ifSnippet = new vscode.CompletionItem('if', vscode.CompletionItemKind.Snippet); - ifSnippet.insertText = new vscode.SnippetString('if ${1:condition} then\n $0\nend if;'); - ifSnippet.detail = "if ... then ... end if;"; - items.push(ifSnippet); - - const forSnippet = new vscode.CompletionItem('for', vscode.CompletionItemKind.Snippet); - forSnippet.insertText = new vscode.SnippetString('for ${1:item} in${2:list} do\n $0\nend for;'); - forSnippet.detail = "for ... in ... do ... end for;"; - items.push(forSnippet); - - const tableSnippet = new vscode.CompletionItem('table', vscode.CompletionItemKind.Snippet); - tableSnippet.insertText = new vscode.SnippetString('table ${1:TableName} 1.0\n ${2:"key"} -> "${3:value}";\n default -> "unknown";\nend table;'); - tableSnippet.detail = "table ... end table;"; - items.push(tableSnippet); - return items; } } \ No newline at end of file diff --git a/src/test/suite/actions.test.ts b/src/test/suite/actions.test.ts new file mode 100644 index 0000000..3f7d622 --- /dev/null +++ b/src/test/suite/actions.test.ts @@ -0,0 +1,34 @@ +import * as assert from 'assert'; +import * as vscode from 'vscode'; +import { TabFix } from '../../providers/actions/TabFix'; +import { LogFix } from '../../providers/actions/LogFix'; + +suite('Code Actions Test Suite', () => { + test('TabFix: Erzeugt Replace-Edit für Tabulatoren', () => { + const fix = new TabFix(); + const diag = new vscode.Diagnostic(new vscode.Range(0, 0, 0, 1), "Tab found", vscode.DiagnosticSeverity.Warning); + + // Mock für TextDocument + const mockDoc = { + uri: vscode.Uri.file('test.tpl'), + lineAt: () => ({ text: '\t\tcode', range: new vscode.Range(0, 0, 0, 6) }) + } as any; + + const actions = fix.provideFix(mockDoc, diag); + assert.strictEqual(actions.length, 1); + assert.strictEqual(actions[0].title, "Tabs in 4 Spaces umwandeln"); + assert.ok(actions[0].edit !== undefined); + }); + + test('LogFix: Erzeugt Insert-Edit mit Log-Statement', () => { + const fix = new LogFix(); + const diag = new vscode.Diagnostic(new vscode.Range(10, 0, 10, 4), "Missing log"); + diag.source = 'log.debug("Start pattern Test");'; // Linter-Source Simulation + + const mockDoc = { uri: vscode.Uri.file('test.tpl') } as any; + + const actions = fix.provideFix(mockDoc, diag); + assert.strictEqual(actions.length, 1); + assert.strictEqual(actions[0].title, "Fehlendes Log-Statement einfügen"); + }); +}); \ No newline at end of file diff --git a/src/test/suite/completions.test.ts b/src/test/suite/completions.test.ts new file mode 100644 index 0000000..8bd5de0 --- /dev/null +++ b/src/test/suite/completions.test.ts @@ -0,0 +1,38 @@ +import * as assert from 'assert'; +import { BuiltInCompletion } from '../../providers/completions/BuiltInCompletion'; +import { SnippetCompletion } from '../../providers/completions/SnippetCompletion'; + +suite('Completion Provider Test Suite', () => { + test('BuiltInCompletion: Reagiert auf "log."', () => { + const strategy = new BuiltInCompletion(); + // Wir faken Document und Position, da wir nur linePrefix brauchen + const result = strategy.provideCompletions(null as any, null as any, ' log.'); + + assert.ok(result !== undefined); + assert.strictEqual(result.length, 4); // debug, info, warn, error + assert.strictEqual(result[0].label, 'debug'); + }); + + test('BuiltInCompletion: Ignoriert normalen Text', () => { + const strategy = new BuiltInCompletion(); + const result = strategy.provideCompletions(null as any, null as any, ' my_var'); + + assert.strictEqual(result, undefined); + }); + + test('SnippetCompletion: Liefert Snippets für normalen Text', () => { + const strategy = new SnippetCompletion(); + const result = strategy.provideCompletions(null as any, null as any, ' i'); + + assert.ok(result !== undefined); + const hasIf = result.some(item => item.label === 'if'); + assert.ok(hasIf, "Muss 'if' Snippet vorschlagen"); + }); + + test('SnippetCompletion: Blockiert Snippets nach Punkt', () => { + const strategy = new SnippetCompletion(); + const result = strategy.provideCompletions(null as any, null as any, ' discovery.r'); + + assert.strictEqual(result, undefined, "Darf keine Block-Snippets in Namespaces anzeigen"); + }); +}); \ No newline at end of file diff --git a/src/test/suite/linter.test.ts b/src/test/suite/linter.test.ts index d15224e..373c5ab 100644 --- a/src/test/suite/linter.test.ts +++ b/src/test/suite/linter.test.ts @@ -1,45 +1,69 @@ import * as assert from 'assert'; +import * as vscode from 'vscode'; import { LinterContext } from '../../core/LinterContext'; import { SyntaxRule } from '../../core/rules/SyntaxRule'; +import { BlockRule } from '../../core/rules/BlockRule'; -suite('Linter SyntaxRule Test Suite', () => { - test('Zuweisung: Warnt bei "=" statt ":="', () => { +suite('Linter Test Suite', () => { + test('SyntaxRule: Warnt bei "=" Zuweisung außerhalb von Bedingungen', () => { const rule = new SyntaxRule(); const ctx = new LinterContext(); + ctx.activePhaseName = 'body'; // Kontext setzen, damit der strict-mode nicht meckert - // Wir simulieren eine Code-Zeile const rawLine = " my_var = 123;"; - rule.checkLine(rawLine.trim(), rawLine, 0, ctx); - - // Erwartung: Ein Fehler wegen falscher Zuweisung - assert.strictEqual(ctx.diagnostics.length, 1); - assert.ok(ctx.diagnostics[0].message.includes("In TPL wird ':=' für Zuweisungen genutzt")); + // HIER GEFIXT: Wir übergeben ein Array als 5. Argument + rule.checkLine(rawLine.trim(), rawLine, 0, ctx, [rawLine]); + + const diag = ctx.diagnostics.find(d => d.message.includes("':=' für Zuweisungen")); + assert.ok(diag !== undefined, "Fehler für falsche Zuweisung erwartet"); }); - test('Zuweisung: Akzeptiert "=" als Vergleich in if-Bedingung', () => { + test('SyntaxRule: Akzeptiert "=" als Vergleich in Bedingungen', () => { const rule = new SyntaxRule(); const ctx = new LinterContext(); + ctx.activePhaseName = 'body'; - // Wir simulieren eine if-Zeile (setzt ctx.inConditionBlock intern auf true) const rawLine = " if my_var = 123 then"; - rule.checkLine(rawLine.trim(), rawLine, 0, ctx); - - // Erwartung: Kein Fehler, da das = als Vergleich legitim ist + rule.checkLine(rawLine.trim(), rawLine, 0, ctx, [rawLine]); + + // Darf keinen Fehler werfen, da inConditionBlock automatisch auf true geht assert.strictEqual(ctx.diagnostics.length, 0); }); - test('Klammern: Findet ungeschlossene runde Klammern', () => { + test('SyntaxRule: Findet fehlendes Semikolon', () => { const rule = new SyntaxRule(); const ctx = new LinterContext(); + ctx.activePhaseName = 'body'; - // Wir öffnen eine Klammer, schließen sie aber nicht - const rawLine = " my_func(parameter;"; - rule.checkLine(rawLine.trim(), rawLine, 0, ctx); - - // checkEnd wird am Ende der Datei aufgerufen - rule.checkEnd(['', rawLine], ctx); + const rawLine = " my_var := 123"; + rule.checkLine(rawLine.trim(), rawLine, 0, ctx, [rawLine]); + + const diag = ctx.diagnostics.find(d => d.message.includes("Fehlendes Semikolon")); + assert.ok(diag !== undefined, "Fehler für fehlendes Semikolon erwartet"); + }); + test('BlockRule: Erzwingt Module-Header', () => { + const rule = new BlockRule(); + const ctx = new LinterContext(); + + const rawLine = "pattern Test 1.0"; + rule.checkLine(rawLine.trim(), rawLine, 0, ctx, [rawLine]); + assert.strictEqual(ctx.diagnostics.length, 1); - assert.ok(ctx.diagnostics[0].message.includes("Öffnende Klammer '(' wird nicht geschlossen!")); + assert.ok(ctx.diagnostics[0].message.includes("tpl 1.x module")); + }); + + test('BlockRule: Findet fehlendes Pattern-Log im Body', () => { + const rule = new BlockRule(); + const ctx = new LinterContext(); + ctx.hasModuleDef = true; + ctx.inBlock = 'pattern'; + ctx.currentPatternName = 'TestPattern'; + + const lines = ['body', ' x := 1;', 'end body;']; + rule.checkLine('body', 'body', 0, ctx, lines); + + const logDiag = ctx.diagnostics.find(d => d.code === 'missing_log_start'); + assert.ok(logDiag !== undefined, "Erwartet Fehler für fehlendes Log-Statement"); }); }); \ No newline at end of file