diff --git a/src/core/LinterContext.ts b/src/core/LinterContext.ts index 302ba48..3e4c915 100644 --- a/src/core/LinterContext.ts +++ b/src/core/LinterContext.ts @@ -14,6 +14,10 @@ export class LinterContext { public inMultiLineComment = false; public inConditionBlock = false; + // Multi-Line Import-Tracking + public inImportBlock = false; + public currentImportModule = ""; + // String-Tracking (Basis-Lexer) public inTripleDouble = false; public inTripleSingle = false; @@ -48,4 +52,4 @@ export class LinterContext { this.justOpenedString = false; this.isClosingString = false; } -} \ No newline at end of file +} diff --git a/src/core/WorkspaceIndex.ts b/src/core/WorkspaceIndex.ts index bd78488..bf33a86 100644 --- a/src/core/WorkspaceIndex.ts +++ b/src/core/WorkspaceIndex.ts @@ -34,7 +34,7 @@ export class WorkspaceIndex { } this.fileModules.set(fsPath, currentModule); - // 2. Definitions-Blöcke und Funktionen parsen + // 2. Blöcke und Funktionen parsen for (let i = 0; i < lines.length; i++) { const line = lines[i]; @@ -50,6 +50,12 @@ export class WorkspaceIndex { continue; } + // Patterns, Syncmappings und Tabellen für den Import freigeben + const structMatch = line.match(/^\s*(?:pattern|syncmapping|table)\s+([a-zA-Z0-9_]+)/); + if (structMatch) { + defBlocks.add(structMatch[1]); + } + // Sucht nach 'define name(param1, param2)' const defineMatch = line.match(/^\s*define\s+([a-zA-Z0-9_]+)\b(?:\s*\((.*?)\))?/); if (defineMatch) { @@ -116,4 +122,4 @@ export class WorkspaceIndex { } } -export const workspaceIndex = new WorkspaceIndex(); \ No newline at end of file +export const workspaceIndex = new WorkspaceIndex(); diff --git a/src/core/rules/SyntaxRule.ts b/src/core/rules/SyntaxRule.ts index 594f1d1..b6c9844 100644 --- a/src/core/rules/SyntaxRule.ts +++ b/src/core/rules/SyntaxRule.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { LinterRule } from './LinterRule'; import { LinterContext } from '../LinterContext'; +import { workspaceIndex } from '../WorkspaceIndex'; export class SyntaxRule implements LinterRule { @@ -13,6 +14,43 @@ export class SyntaxRule implements LinterRule { if (codePart.length === 0) {return;} + // --- MULTI-LINE IMPORT HANDLING & VALIDATION --- + if (codePart.startsWith('from ')) { + const match = codePart.match(/^from\s+([a-zA-Z0-9_.]+)\s+import/); + if (match) { + ctx.inImportBlock = true; + ctx.currentImportModule = match[1]; + } + } + + if (ctx.inImportBlock) { + // Remove 'from X import' and any trailing semicolons + const importString = codePart.replace(/^from\s+[a-zA-Z0-9_.]+\s+import/, '').replace(/;$/, ''); + const imports = importString.split(',').filter(i => i.trim().length > 0); + + // Check against RAM index + const availableItems = workspaceIndex.getDefinitionBlocksForModule(ctx.currentImportModule); + + // Only warn if we actually have the module in RAM, but the specific item is missing + if (availableItems.length > 0) { + for (const imp of imports) { + const cleanImp = imp.trim().split(/\s+/)[0]; // Trims version number (e.g. "HostToPoolMember 2.0" -> "HostToPoolMember") + if (cleanImp.length > 0 && !availableItems.includes(cleanImp)) { + const idx = rawLine.indexOf(cleanImp); + ctx.addDiag(lineIdx, Math.max(0, idx), Math.max(0, idx) + cleanImp.length, `The item '${cleanImp}' does not exist in module '${ctx.currentImportModule}'.`, vscode.DiagnosticSeverity.Error); + } + } + } + + // If the line ends with a semicolon, the import block is closed + if (codePart.endsWith(';')) { + ctx.inImportBlock = false; + ctx.currentImportModule = ""; + } + return; // Skip all other syntax checks for lines inside an import block + } + // ------------------------------------------------ + 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("'")) { @@ -31,9 +69,9 @@ export class SyntaxRule implements LinterRule { ctx.inBlock === 'definitions' || ctx.openDefines.length > 0; - // 'then' und 'do' auf alleinstehenden Zeilen sind legitimer Code + // 'then' and 'do' on standalone lines are legitimate 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); + ctx.addDiag(lineIdx, 0, rawLine.length, "Executable code is not allowed here. (Allowed in: 'body', 'triggers', 'constants', 'overview', 'mapping', 'metadata', 'definitions', or 'define').", vscode.DiagnosticSeverity.Error); } this.checkBrackets(codePart, rawLine, lineIdx, ctx); @@ -42,14 +80,14 @@ export class SyntaxRule implements LinterRule { } public checkEnd(lines: string[], ctx: LinterContext): void { - for (const unclosedLine of ctx.parenStack) { ctx.addDiag(unclosedLine, 0, lines[unclosedLine].length, "Öffnende Klammer '(' wird nicht geschlossen!", vscode.DiagnosticSeverity.Error); } - for (const unclosedLine of ctx.bracketStack) { ctx.addDiag(unclosedLine, 0, lines[unclosedLine].length, "Öffnende eckige Klammer '[' wird nicht geschlossen!", vscode.DiagnosticSeverity.Error); } + for (const unclosedLine of ctx.parenStack) { ctx.addDiag(unclosedLine, 0, lines[unclosedLine].length, "Opening parenthesis '(' is not closed!", vscode.DiagnosticSeverity.Error); } + for (const unclosedLine of ctx.bracketStack) { ctx.addDiag(unclosedLine, 0, lines[unclosedLine].length, "Opening bracket '[' is not closed!", vscode.DiagnosticSeverity.Error); } } private checkTabs(rawLine: string, lineIdx: number, ctx: LinterContext) { const tabIndex = rawLine.indexOf('\t'); if (tabIndex !== -1) { - const diag = ctx.addDiag(lineIdx, tabIndex, tabIndex + 1, "Bitte Tabs durch Spaces ersetzen", vscode.DiagnosticSeverity.Warning); + const diag = ctx.addDiag(lineIdx, tabIndex, tabIndex + 1, "Please replace tabs with spaces", vscode.DiagnosticSeverity.Warning); diag.code = 'invalid_tab'; } } @@ -60,12 +98,12 @@ export class SyntaxRule implements LinterRule { if (char === '(') { ctx.parenStack.push(lineIdx); } else if (char === ')') { if (ctx.parenStack.length > 0) { ctx.parenStack.pop(); } - else { ctx.addDiag(lineIdx, 0, rawLine.length, "Unerwartete schließende Klammer ')'. Es fehlt eine öffnende Klammer.", vscode.DiagnosticSeverity.Error); } + else { ctx.addDiag(lineIdx, 0, rawLine.length, "Unexpected closing parenthesis ')'. Missing an opening parenthesis.", vscode.DiagnosticSeverity.Error); } } else if (char === '[') { ctx.bracketStack.push(lineIdx); } else if (char === ']') { if (ctx.bracketStack.length > 0) { ctx.bracketStack.pop(); } - else { ctx.addDiag(lineIdx, 0, rawLine.length, "Unerwartete schließende eckige Klammer ']'. Es fehlt eine öffnende Klammer.", vscode.DiagnosticSeverity.Error); } + else { ctx.addDiag(lineIdx, 0, rawLine.length, "Unexpected closing bracket ']'. Missing an opening bracket.", vscode.DiagnosticSeverity.Error); } } } } @@ -77,7 +115,7 @@ export class SyntaxRule implements LinterRule { 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); + ctx.addDiag(lineIdx, idx, idx + 1, "Invalid assignment. In TPL, ':=' is used for assignments.", vscode.DiagnosticSeverity.Error); } } } @@ -88,20 +126,22 @@ export class SyntaxRule implements LinterRule { private checkSemicolons(codePart: string, rawLine: string, lineIdx: number, ctx: LinterContext, originalTrimmedLine: string) { if (ctx.isClosingString) { 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); + ctx.addDiag(lineIdx, Math.max(0, rawLine.length - 1), rawLine.length, "Missing semicolon at the end of the assignment.", vscode.DiagnosticSeverity.Error); } } else { + // "then" and "do" can stand alone without a semicolon requirement 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); + // "in" (for loops) is a valid line break const continuesOnNextLine = /(,|and|or|not|\+|-|\*|\/|=|:=|matches|in)$/i.test(codePart); 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); + ctx.addDiag(lineIdx, Math.max(0, rawLine.length - 1), rawLine.length, "Missing semicolon at the end of the statement.", vscode.DiagnosticSeverity.Error); } } } } -} \ No newline at end of file +} diff --git a/src/providers/completions/LocalContextCompletion.ts b/src/providers/completions/LocalContextCompletion.ts index e92c31b..4b038a2 100644 --- a/src/providers/completions/LocalContextCompletion.ts +++ b/src/providers/completions/LocalContextCompletion.ts @@ -8,26 +8,34 @@ export class LocalContextCompletion implements CompletionStrategy { const text = document.getText(); const items: vscode.CompletionItem[] = []; - // 1. SMART IMPORT: "from " - if (linePrefix.match(/\bfrom\s+$/)) { - for (const mod of workspaceIndex.getAllModules()) { - const item = new vscode.CompletionItem(mod, vscode.CompletionItemKind.Module); - item.detail = "Workspace Module"; - items.push(item); - } - return items; // Nichts anderes vorschlagen! - } + const textBeforeCursor = document.getText(new vscode.Range(new vscode.Position(0, 0), position)); + const lastFromIndex = textBeforeCursor.lastIndexOf('from '); + const lastSemicolonIndex = textBeforeCursor.lastIndexOf(';'); - // 2. SMART IMPORT: "from X import " - const importContextMatch = linePrefix.match(/\bfrom\s+([a-zA-Z0-9_.]+)\s+import\s+$/); - if (importContextMatch) { - const modName = importContextMatch[1]; - for (const defBlock of workspaceIndex.getDefinitionBlocksForModule(modName)) { - const item = new vscode.CompletionItem(defBlock, vscode.CompletionItemKind.Class); - item.detail = `Definitions from ${modName}`; - items.push(item); + // 1 & 2. MULTI-LINE SMART IMPORT + // Wenn das letzte 'from' NACH dem letzten Semikolon kam, sind wir mitten in einem Import-Block! + if (lastFromIndex > lastSemicolonIndex || (lastFromIndex !== -1 && lastSemicolonIndex === -1)) { + const importContext = textBeforeCursor.substring(lastFromIndex); + const match = importContext.match(/^from\s+([a-zA-Z0-9_.]+)\s+import/); + + if (!match) { + // Wir tippen noch den Modul-Namen + for (const mod of workspaceIndex.getAllModules()) { + const item = new vscode.CompletionItem(mod, vscode.CompletionItemKind.Module); + item.detail = "Workspace Module"; + items.push(item); + } + return items; + } else { + // Wir tippen die zu importierenden Klassen/Patterns (auch nach Zeilenumbrüchen!) + const modName = match[1]; + for (const defBlock of workspaceIndex.getDefinitionBlocksForModule(modName)) { + const item = new vscode.CompletionItem(defBlock, vscode.CompletionItemKind.Class); + item.detail = `Available in ${modName}`; + items.push(item); + } + return items; // Nichts anderes vorschlagen! } - return items; // Nichts anderes vorschlagen! } // 3. NAMESPACE CALLS: "prefix." @@ -44,7 +52,7 @@ export class LocalContextCompletion implements CompletionStrategy { let funcs = []; // A) RAM-Abfrage für importierte Module - const importRegex = new RegExp(`from\\s+([a-zA-Z0-9_.]+)\\s+import\\s+${prefix}\\b`); + const importRegex = new RegExp(`from\\s+([a-zA-Z0-9_.]+)\\s+import\\s+[\\s\\S]*?\\b${prefix}\\b`); const match = importRegex.exec(text); if (match) { const moduleName = match[1]; @@ -76,7 +84,7 @@ export class LocalContextCompletion implements CompletionStrategy { const item = new vscode.CompletionItem(shortName, vscode.CompletionItemKind.Function); item.detail = f.moduleName === 'local' ? `Local Method` : `Method from ${f.moduleName}`; - // FIX: Dynamische Parameter-Generierung! Baut z.B. ${1:host}, ${2:ip} + // Dynamische Parameter-Generierung! Baut z.B. ${1:host}, ${2:ip} let paramSnippet = ""; if (f.params && f.params.length > 0) { paramSnippet = f.params.map((p, idx) => `\${${idx + 1}:${p}}`).join(', '); @@ -85,10 +93,10 @@ export class LocalContextCompletion implements CompletionStrategy { item.insertText = new vscode.SnippetString(`${shortName}(${paramSnippet});$0`); items.push(item); } - return items; // Gibt exakt die Funktionen zurück + return items; // Gibt exakt die gefundenen Funktionen zurück } - // FIX: Wenn es ein unbekannter Namespace mit Punkt ist, geben wir ein leeres Array [] zurück! + // Wenn es ein unbekannter Namespace mit Punkt ist, geben wir ein leeres Array [] zurück! // Das zwingt VS Code dazu, KEINE generischen Textwörter vorzuschlagen. return []; } @@ -110,7 +118,7 @@ export class LocalContextCompletion implements CompletionStrategy { const item = new vscode.CompletionItem(fullName, vscode.CompletionItemKind.Function); item.detail = currentDefBlock ? `Local Function in '${currentDefBlock}'` : `Local Function`; - // Dynamische Parameter-Generierung auch hier! + // Dynamische Parameter-Generierung const params = (defineMatch[2] || "").split(',').map(p => p.trim()).filter(p => p.length > 0); let paramSnippet = ""; if (params.length > 0) { @@ -122,13 +130,20 @@ export class LocalContextCompletion implements CompletionStrategy { } } - // Import-Referenzen für den globalen Scope bereitstellen - const fromImportRegex = /from\s+([a-zA-Z0-9_.]+)\s+import\s+([a-zA-Z0-9_]+)/g; + // Import-Referenzen für den globalen Scope bereitstellen (angepasst für Multi-Line) + const fromImportRegex = /from\s+([a-zA-Z0-9_.]+)\s+import\s+([\s\S]*?);/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 moduleName = matchVar[1]; + const imports = matchVar[2].split(',').map(i => i.trim().split(/\s+/)[0]); + + for (const imp of imports) { + if (imp.length > 0) { + const item = new vscode.CompletionItem(imp, vscode.CompletionItemKind.Module); + item.detail = `Imported from ${moduleName}`; + items.push(item); + } + } } // Lokale Variablen (:=) vorschlagen diff --git a/src/test/suite/completions.test.ts b/src/test/suite/completions.test.ts index 324603e..a24d8f5 100644 --- a/src/test/suite/completions.test.ts +++ b/src/test/suite/completions.test.ts @@ -61,10 +61,10 @@ end definitions; assert.ok(result !== undefined); assert.strictEqual(result!.length, 0, "Must return an empty array to block VS Code text spam."); }); - - test('LocalContextCompletion: Imported namespace generates parameter snippet', async () => { +test('LocalContextCompletion: Imported namespace generates parameter snippet', async () => { const strategy = new LocalContextCompletion(); - const docText = `from Company.tools import network_helpers\nnetwork_helpers.`; + + const docText = `from Company.tools import network_helpers;\nnetwork_helpers.`; const mockDoc = createMockDoc(docText); const position = new vscode.Position(1, 16);