From 2be24e04af2047b251c8647208071e625857d3d0 Mon Sep 17 00:00:00 2001 From: Dillon Date: Fri, 12 Jun 2026 10:39:27 -0700 Subject: [PATCH 1/4] feat: support go-to-definition for direct flow.X / conv.X function calls Previously, only `flow.functions.func_name` and `conv.functions.func_name` were clickable. Now `flow.func_name` and `conv.func_name` also navigate to the function definition file, with matching hover and find-references support. Known runtime attributes (e.g. flow.goto_step, conv.say) are excluded so they continue to show their runtime tooltips. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pythonLanguageFeatures.ts | 200 +++++++++++++++++++--------------- 1 file changed, 110 insertions(+), 90 deletions(-) diff --git a/src/pythonLanguageFeatures.ts b/src/pythonLanguageFeatures.ts index 6e356127..7492321d 100644 --- a/src/pythonLanguageFeatures.ts +++ b/src/pythonLanguageFeatures.ts @@ -9,7 +9,9 @@ import { conversationMembers, flowMembers, RuntimeMember } from './generated/run export const GOTO_STEP_PATTERN = /flow\.goto_step\(\s*["']([^"']+)["']/g; /** - * Helper function to extract function call pattern from a line at a given position + * Helper function to extract function call pattern from a line at a given position. + * Matches both qualified calls (conv.functions.X / flow.functions.X) and + * direct calls (conv.X / flow.X) where X is not a known runtime attribute. * Returns { type: 'conv' | 'flow', functionName: string, range: vscode.Range } or null */ function extractFunctionCall( @@ -20,73 +22,75 @@ function extractFunctionCall( const lineText = line.text; const offset = position.character; - // Quick check: if the line doesn't contain "conv.functions" or "flow.functions", return immediately - if (!lineText.includes('conv.functions') && !lineText.includes('flow.functions')) { + if (!lineText.includes('conv.') && !lineText.includes('flow.')) { return null; } - // First, try to get the word at the cursor position - // This helps when user clicks directly on the function name const wordRange = document.getWordRangeAtPosition(position, /\w+/); let searchStart = 0; let searchEnd = lineText.length; - + if (wordRange) { - // Expand search to include context around the word - // Look backwards up to 50 characters to find "conv.functions." or "flow.functions." searchStart = Math.max(0, wordRange.start.character - 50); searchEnd = Math.min(lineText.length, wordRange.end.character + 50); } - + const searchText = lineText.substring(searchStart, searchEnd); - // Try to match conv.functions.function_name or flow.functions.function_name - // This regex matches the full pattern including the function name - const patterns = [ - // Match conv.functions.function_name (with optional parentheses and arguments) - { - regex: /conv\.functions\.(\w+)(?:\([^)]*\))?/g, - type: 'conv' as const - }, - // Match flow.functions.function_name (with optional parentheses and arguments) - { - regex: /flow\.functions\.(\w+)(?:\([^)]*\))?/g, - type: 'flow' as const + // Phase 1: Try qualified calls — conv.functions.X / flow.functions.X + if (lineText.includes('.functions.')) { + const qualifiedPatterns = [ + { regex: /conv\.functions\.(\w+)(?:\([^)]*\))?/g, type: 'conv' as const }, + { regex: /flow\.functions\.(\w+)(?:\([^)]*\))?/g, type: 'flow' as const } + ]; + + for (const pattern of qualifiedPatterns) { + let match; + pattern.regex.lastIndex = 0; + while ((match = pattern.regex.exec(searchText)) !== null) { + const absoluteMatchStart = searchStart + match.index; + const absoluteMatchEnd = searchStart + match.index + match[0].length; + + if (offset >= absoluteMatchStart && offset <= absoluteMatchEnd) { + const functionName = match[1]; + const functionNameOffset = match[0].indexOf(functionName); + const functionNameStart = absoluteMatchStart + functionNameOffset; + const functionNameEnd = functionNameStart + functionName.length; + + if (offset >= functionNameStart && offset <= functionNameEnd) { + const startPos = new vscode.Position(position.line, functionNameStart); + const endPos = new vscode.Position(position.line, functionNameEnd); + return { type: pattern.type, functionName, range: new vscode.Range(startPos, endPos) }; + } + return null; + } + } } + } + + // Phase 2: Try direct calls — conv.X / flow.X where X is not a known runtime attribute + const directPatterns = [ + { regex: /\bconv\.(\w+)/g, type: 'conv' as const, members: conversationMembers }, + { regex: /\bflow\.(\w+)/g, type: 'flow' as const, members: flowMembers } ]; - for (const pattern of patterns) { + for (const pattern of directPatterns) { let match; - pattern.regex.lastIndex = 0; // Reset regex + pattern.regex.lastIndex = 0; while ((match = pattern.regex.exec(searchText)) !== null) { - const matchStart = match.index; - const matchEnd = match.index + match[0].length; - - // The match position is relative to searchText, so we need to adjust - const absoluteMatchStart = searchStart + matchStart; - const absoluteMatchEnd = searchStart + matchEnd; - - // Check if the cursor position is within this match - if (offset >= absoluteMatchStart && offset <= absoluteMatchEnd) { - const functionName = match[1]; - // Find where the function name starts in the match - const functionNameOffset = match[0].indexOf(functionName); - const functionNameStart = absoluteMatchStart + functionNameOffset; - const functionNameEnd = functionNameStart + functionName.length; - - // Only return a result if the cursor is specifically on the function name part - // Not on "conv", "flow", or "functions" - if (offset >= functionNameStart && offset <= functionNameEnd) { - const startPos = new vscode.Position(position.line, functionNameStart); - const endPos = new vscode.Position(position.line, functionNameEnd); - return { - type: pattern.type, - functionName, - range: new vscode.Range(startPos, endPos) - }; - } - // If cursor is on "conv", "flow", or "functions", return null - return null; + const attr = match[1]; + + if (attr === 'functions' || pattern.members[attr]) continue; + + const absoluteMatchStart = searchStart + match.index; + const dotIndex = match[0].indexOf('.'); + const attrStart = absoluteMatchStart + dotIndex + 1; + const attrEnd = attrStart + attr.length; + + if (offset >= attrStart && offset <= attrEnd) { + const startPos = new vscode.Position(position.line, attrStart); + const endPos = new vscode.Position(position.line, attrEnd); + return { type: pattern.type, functionName: attr, range: new vscode.Range(startPos, endPos) }; } } } @@ -496,22 +500,18 @@ async function findFunctionReferences( token: vscode.CancellationToken ): Promise { const locations: vscode.Location[] = []; - - // Escape the function name for regex + const escapedFunctionName = functionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - - // Build search pattern - match conv.functions.functionName or flow.functions.functionName - // with optional whitespace and parentheses - const pattern = type === 'conv' - ? new RegExp(`conv\\.functions\\.${escapedFunctionName}(?:\\s*\\([^)]*\\))?`, 'g') - : new RegExp(`flow\\.functions\\.${escapedFunctionName}(?:\\s*\\([^)]*\\))?`, 'g'); - - // Quick string check pattern (for fast filtering before regex) - const quickCheckPattern = type === 'conv' - ? `conv.functions.${functionName}` - : `flow.functions.${functionName}`; - - debugLog(`Searching for ${type === 'conv' ? 'conv' : 'flow'}.functions.${functionName}`); + const prefix = type === 'conv' ? 'conv' : 'flow'; + + // Match both qualified (conv.functions.X) and direct (conv.X) call patterns + const qualifiedPattern = new RegExp(`${prefix}\\.functions\\.${escapedFunctionName}(?:\\s*\\([^)]*\\))?`, 'g'); + const directPattern = new RegExp(`\\b${prefix}\\.${escapedFunctionName}\\b`, 'g'); + + const quickCheckQualified = `${prefix}.functions.${functionName}`; + const quickCheckDirect = `${prefix}.${functionName}`; + + debugLog(`Searching for ${prefix}.functions.${functionName} and ${prefix}.${functionName}`); try { // Get all Python files in the workspace (with limit) @@ -545,39 +545,59 @@ async function findFunctionReferences( } try { - // Read file directly (faster than opening as document) const fileContent = fs.readFileSync(fileUri.fsPath, 'utf8'); - - // Quick check: skip if pattern not found - if (!fileContent.includes(quickCheckPattern)) { + + const hasQualified = fileContent.includes(quickCheckQualified); + const hasDirect = fileContent.includes(quickCheckDirect); + if (!hasQualified && !hasDirect) { continue; } - - // Split into lines and search + const lines = fileContent.split('\n'); for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { if (token.isCancellationRequested) { break; } - + const line = lines[lineIndex]; - let match; - pattern.lastIndex = 0; // Reset regex - - while ((match = pattern.exec(line)) !== null) { - // Find the function name within the match - const functionNameOffset = match[0].indexOf(functionName); - if (functionNameOffset !== -1) { - const functionNameStart = match.index + functionNameOffset; - const functionNameEnd = functionNameStart + functionName.length; - - locations.push(new vscode.Location( - fileUri, - new vscode.Range( - new vscode.Position(lineIndex, functionNameStart), - new vscode.Position(lineIndex, functionNameEnd) - ) - )); + const matchedPositions = new Set(); + + // Search qualified pattern first + if (hasQualified) { + let match; + qualifiedPattern.lastIndex = 0; + while ((match = qualifiedPattern.exec(line)) !== null) { + const fnOffset = match[0].indexOf(functionName); + if (fnOffset !== -1) { + const fnStart = match.index + fnOffset; + matchedPositions.add(fnStart); + locations.push(new vscode.Location( + fileUri, + new vscode.Range( + new vscode.Position(lineIndex, fnStart), + new vscode.Position(lineIndex, fnStart + functionName.length) + ) + )); + } + } + } + + // Search direct pattern, skipping positions already found by qualified + if (hasDirect) { + let match; + directPattern.lastIndex = 0; + while ((match = directPattern.exec(line)) !== null) { + const dotIdx = match[0].indexOf('.'); + const fnStart = match.index + dotIdx + 1; + if (!matchedPositions.has(fnStart)) { + locations.push(new vscode.Location( + fileUri, + new vscode.Range( + new vscode.Position(lineIndex, fnStart), + new vscode.Position(lineIndex, fnStart + functionName.length) + ) + )); + } } } } From 45bf673be46544f3f04bbd324c96c45a6125f3c2 Mon Sep 17 00:00:00 2001 From: Dillon Date: Fri, 12 Jun 2026 10:53:14 -0700 Subject: [PATCH 2/4] fix: add DocumentLinkProvider to bypass Pylance __getattr__ on Ctrl+Click DocumentLinks take priority over definition providers on Ctrl+Click. This means clicking flow.func_name or conv.func_name navigates directly to the function file instead of showing Pylance's __getattr__ result. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/extension.ts | 12 +++++++-- src/pythonLanguageFeatures.ts | 51 +++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/extension.ts b/src/extension.ts index d09d9c0e..0799c43c 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,7 +8,7 @@ import * as yaml from 'js-yaml'; import { FlowParser } from './flowParser'; import { getWebviewContent, getErrorWebviewContent } from './webview/webviewContent'; import { WebviewMessageHandler } from './webview/webviewHandlers'; -import { PythonDefinitionProvider, PythonHoverProvider, PythonReferencesProvider, PythonCompletionProvider } from './pythonLanguageFeatures'; +import { PythonDefinitionProvider, PythonHoverProvider, PythonReferencesProvider, PythonCompletionProvider, PythonFunctionLinkProvider } from './pythonLanguageFeatures'; import { initializeDebug, toggleDebugMode, debugLog } from './utils/debug'; import { AgentStudioLinter } from './linter'; @@ -248,6 +248,13 @@ conditions: [] new PythonCompletionProvider(), '.' ); + + // DocumentLink provider for direct flow.X / conv.X calls — takes priority + // over definition providers on Ctrl+Click, bypassing Pylance's __getattr__ + const pythonFunctionLinkProvider = vscode.languages.registerDocumentLinkProvider( + { language: 'python', scheme: 'file' }, + new PythonFunctionLinkProvider() + ); debugLog('Python language features registered'); // Initialize and activate the Agent Studio Linter @@ -263,7 +270,8 @@ conditions: [] pythonDefinitionProvider, pythonHoverProvider, pythonReferencesProvider, - pythonCompletionProvider + pythonCompletionProvider, + pythonFunctionLinkProvider ); } diff --git a/src/pythonLanguageFeatures.ts b/src/pythonLanguageFeatures.ts index 7492321d..129a91cf 100644 --- a/src/pythonLanguageFeatures.ts +++ b/src/pythonLanguageFeatures.ts @@ -650,3 +650,54 @@ export class PythonReferencesProvider implements vscode.ReferenceProvider { } } +/** + * Document link provider for direct flow.X / conv.X function calls. + * DocumentLinks take priority over definition providers on Ctrl+Click, + * so this bypasses Pylance's __getattr__ result and navigates directly + * to the function file. + */ +export class PythonFunctionLinkProvider implements vscode.DocumentLinkProvider { + provideDocumentLinks( + document: vscode.TextDocument, + _token: vscode.CancellationToken + ): vscode.ProviderResult { + const links: vscode.DocumentLink[] = []; + const lineCount = document.lineCount; + + const patterns = [ + { regex: /\bconv\.(\w+)/g, type: 'conv' as const, members: conversationMembers }, + { regex: /\bflow\.(\w+)/g, type: 'flow' as const, members: flowMembers } + ]; + + for (let i = 0; i < lineCount; i++) { + const line = document.lineAt(i).text; + if (!line.includes('conv.') && !line.includes('flow.')) continue; + + for (const pattern of patterns) { + let match; + pattern.regex.lastIndex = 0; + while ((match = pattern.regex.exec(line)) !== null) { + const attr = match[1]; + if (attr === 'functions' || pattern.members[attr]) continue; + + const location = pattern.type === 'conv' + ? PythonFunctionResolver.resolveConvFunction(attr, document) + : PythonFunctionResolver.resolveFlowFunction(attr, document); + + if (location) { + const dotIndex = match[0].indexOf('.'); + const attrStart = match.index + dotIndex + 1; + const range = new vscode.Range( + new vscode.Position(i, attrStart), + new vscode.Position(i, attrStart + attr.length) + ); + links.push(new vscode.DocumentLink(range, location.uri)); + } + } + } + } + + return links; + } +} + From 5426c83485a20ee86c58f2215614c4e548137c7d Mon Sep 17 00:00:00 2001 From: Dillon Date: Fri, 12 Jun 2026 10:57:17 -0700 Subject: [PATCH 3/4] fix: target qualified flow.functions.X / conv.functions.X patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the direct flow.X / conv.X changes — the actual patterns are flow.functions.X and conv.functions.X. The DocumentLinkProvider now creates links for these qualified patterns so Ctrl+Click navigates directly to the function file without showing Pylance's __getattr__. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pythonLanguageFeatures.ts | 204 +++++++++++++++------------------- 1 file changed, 91 insertions(+), 113 deletions(-) diff --git a/src/pythonLanguageFeatures.ts b/src/pythonLanguageFeatures.ts index 129a91cf..12145e7c 100644 --- a/src/pythonLanguageFeatures.ts +++ b/src/pythonLanguageFeatures.ts @@ -9,9 +9,7 @@ import { conversationMembers, flowMembers, RuntimeMember } from './generated/run export const GOTO_STEP_PATTERN = /flow\.goto_step\(\s*["']([^"']+)["']/g; /** - * Helper function to extract function call pattern from a line at a given position. - * Matches both qualified calls (conv.functions.X / flow.functions.X) and - * direct calls (conv.X / flow.X) where X is not a known runtime attribute. + * Helper function to extract function call pattern from a line at a given position * Returns { type: 'conv' | 'flow', functionName: string, range: vscode.Range } or null */ function extractFunctionCall( @@ -22,75 +20,73 @@ function extractFunctionCall( const lineText = line.text; const offset = position.character; - if (!lineText.includes('conv.') && !lineText.includes('flow.')) { + // Quick check: if the line doesn't contain "conv.functions" or "flow.functions", return immediately + if (!lineText.includes('conv.functions') && !lineText.includes('flow.functions')) { return null; } + // First, try to get the word at the cursor position + // This helps when user clicks directly on the function name const wordRange = document.getWordRangeAtPosition(position, /\w+/); let searchStart = 0; let searchEnd = lineText.length; if (wordRange) { + // Expand search to include context around the word + // Look backwards up to 50 characters to find "conv.functions." or "flow.functions." searchStart = Math.max(0, wordRange.start.character - 50); searchEnd = Math.min(lineText.length, wordRange.end.character + 50); } const searchText = lineText.substring(searchStart, searchEnd); - // Phase 1: Try qualified calls — conv.functions.X / flow.functions.X - if (lineText.includes('.functions.')) { - const qualifiedPatterns = [ - { regex: /conv\.functions\.(\w+)(?:\([^)]*\))?/g, type: 'conv' as const }, - { regex: /flow\.functions\.(\w+)(?:\([^)]*\))?/g, type: 'flow' as const } - ]; - - for (const pattern of qualifiedPatterns) { - let match; - pattern.regex.lastIndex = 0; - while ((match = pattern.regex.exec(searchText)) !== null) { - const absoluteMatchStart = searchStart + match.index; - const absoluteMatchEnd = searchStart + match.index + match[0].length; - - if (offset >= absoluteMatchStart && offset <= absoluteMatchEnd) { - const functionName = match[1]; - const functionNameOffset = match[0].indexOf(functionName); - const functionNameStart = absoluteMatchStart + functionNameOffset; - const functionNameEnd = functionNameStart + functionName.length; - - if (offset >= functionNameStart && offset <= functionNameEnd) { - const startPos = new vscode.Position(position.line, functionNameStart); - const endPos = new vscode.Position(position.line, functionNameEnd); - return { type: pattern.type, functionName, range: new vscode.Range(startPos, endPos) }; - } - return null; - } - } + // Try to match conv.functions.function_name or flow.functions.function_name + // This regex matches the full pattern including the function name + const patterns = [ + // Match conv.functions.function_name (with optional parentheses and arguments) + { + regex: /conv\.functions\.(\w+)(?:\([^)]*\))?/g, + type: 'conv' as const + }, + // Match flow.functions.function_name (with optional parentheses and arguments) + { + regex: /flow\.functions\.(\w+)(?:\([^)]*\))?/g, + type: 'flow' as const } - } - - // Phase 2: Try direct calls — conv.X / flow.X where X is not a known runtime attribute - const directPatterns = [ - { regex: /\bconv\.(\w+)/g, type: 'conv' as const, members: conversationMembers }, - { regex: /\bflow\.(\w+)/g, type: 'flow' as const, members: flowMembers } ]; - for (const pattern of directPatterns) { + for (const pattern of patterns) { let match; - pattern.regex.lastIndex = 0; + pattern.regex.lastIndex = 0; // Reset regex while ((match = pattern.regex.exec(searchText)) !== null) { - const attr = match[1]; - - if (attr === 'functions' || pattern.members[attr]) continue; - - const absoluteMatchStart = searchStart + match.index; - const dotIndex = match[0].indexOf('.'); - const attrStart = absoluteMatchStart + dotIndex + 1; - const attrEnd = attrStart + attr.length; - - if (offset >= attrStart && offset <= attrEnd) { - const startPos = new vscode.Position(position.line, attrStart); - const endPos = new vscode.Position(position.line, attrEnd); - return { type: pattern.type, functionName: attr, range: new vscode.Range(startPos, endPos) }; + const matchStart = match.index; + const matchEnd = match.index + match[0].length; + + // The match position is relative to searchText, so we need to adjust + const absoluteMatchStart = searchStart + matchStart; + const absoluteMatchEnd = searchStart + matchEnd; + + // Check if the cursor position is within this match + if (offset >= absoluteMatchStart && offset <= absoluteMatchEnd) { + const functionName = match[1]; + // Find where the function name starts in the match + const functionNameOffset = match[0].indexOf(functionName); + const functionNameStart = absoluteMatchStart + functionNameOffset; + const functionNameEnd = functionNameStart + functionName.length; + + // Only return a result if the cursor is specifically on the function name part + // Not on "conv", "flow", or "functions" + if (offset >= functionNameStart && offset <= functionNameEnd) { + const startPos = new vscode.Position(position.line, functionNameStart); + const endPos = new vscode.Position(position.line, functionNameEnd); + return { + type: pattern.type, + functionName, + range: new vscode.Range(startPos, endPos) + }; + } + // If cursor is on "conv", "flow", or "functions", return null + return null; } } } @@ -501,17 +497,21 @@ async function findFunctionReferences( ): Promise { const locations: vscode.Location[] = []; + // Escape the function name for regex const escapedFunctionName = functionName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const prefix = type === 'conv' ? 'conv' : 'flow'; - // Match both qualified (conv.functions.X) and direct (conv.X) call patterns - const qualifiedPattern = new RegExp(`${prefix}\\.functions\\.${escapedFunctionName}(?:\\s*\\([^)]*\\))?`, 'g'); - const directPattern = new RegExp(`\\b${prefix}\\.${escapedFunctionName}\\b`, 'g'); + // Build search pattern - match conv.functions.functionName or flow.functions.functionName + // with optional whitespace and parentheses + const pattern = type === 'conv' + ? new RegExp(`conv\\.functions\\.${escapedFunctionName}(?:\\s*\\([^)]*\\))?`, 'g') + : new RegExp(`flow\\.functions\\.${escapedFunctionName}(?:\\s*\\([^)]*\\))?`, 'g'); - const quickCheckQualified = `${prefix}.functions.${functionName}`; - const quickCheckDirect = `${prefix}.${functionName}`; + // Quick string check pattern (for fast filtering before regex) + const quickCheckPattern = type === 'conv' + ? `conv.functions.${functionName}` + : `flow.functions.${functionName}`; - debugLog(`Searching for ${prefix}.functions.${functionName} and ${prefix}.${functionName}`); + debugLog(`Searching for ${type === 'conv' ? 'conv' : 'flow'}.functions.${functionName}`); try { // Get all Python files in the workspace (with limit) @@ -545,14 +545,15 @@ async function findFunctionReferences( } try { + // Read file directly (faster than opening as document) const fileContent = fs.readFileSync(fileUri.fsPath, 'utf8'); - const hasQualified = fileContent.includes(quickCheckQualified); - const hasDirect = fileContent.includes(quickCheckDirect); - if (!hasQualified && !hasDirect) { + // Quick check: skip if pattern not found + if (!fileContent.includes(quickCheckPattern)) { continue; } + // Split into lines and search const lines = fileContent.split('\n'); for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { if (token.isCancellationRequested) { @@ -560,44 +561,23 @@ async function findFunctionReferences( } const line = lines[lineIndex]; - const matchedPositions = new Set(); - - // Search qualified pattern first - if (hasQualified) { - let match; - qualifiedPattern.lastIndex = 0; - while ((match = qualifiedPattern.exec(line)) !== null) { - const fnOffset = match[0].indexOf(functionName); - if (fnOffset !== -1) { - const fnStart = match.index + fnOffset; - matchedPositions.add(fnStart); - locations.push(new vscode.Location( - fileUri, - new vscode.Range( - new vscode.Position(lineIndex, fnStart), - new vscode.Position(lineIndex, fnStart + functionName.length) - ) - )); - } - } - } - - // Search direct pattern, skipping positions already found by qualified - if (hasDirect) { - let match; - directPattern.lastIndex = 0; - while ((match = directPattern.exec(line)) !== null) { - const dotIdx = match[0].indexOf('.'); - const fnStart = match.index + dotIdx + 1; - if (!matchedPositions.has(fnStart)) { - locations.push(new vscode.Location( - fileUri, - new vscode.Range( - new vscode.Position(lineIndex, fnStart), - new vscode.Position(lineIndex, fnStart + functionName.length) - ) - )); - } + let match; + pattern.lastIndex = 0; // Reset regex + + while ((match = pattern.exec(line)) !== null) { + // Find the function name within the match + const functionNameOffset = match[0].indexOf(functionName); + if (functionNameOffset !== -1) { + const functionNameStart = match.index + functionNameOffset; + const functionNameEnd = functionNameStart + functionName.length; + + locations.push(new vscode.Location( + fileUri, + new vscode.Range( + new vscode.Position(lineIndex, functionNameStart), + new vscode.Position(lineIndex, functionNameEnd) + ) + )); } } } @@ -651,7 +631,7 @@ export class PythonReferencesProvider implements vscode.ReferenceProvider { } /** - * Document link provider for direct flow.X / conv.X function calls. + * Document link provider for conv.functions.X / flow.functions.X calls. * DocumentLinks take priority over definition providers on Ctrl+Click, * so this bypasses Pylance's __getattr__ result and navigates directly * to the function file. @@ -665,31 +645,29 @@ export class PythonFunctionLinkProvider implements vscode.DocumentLinkProvider { const lineCount = document.lineCount; const patterns = [ - { regex: /\bconv\.(\w+)/g, type: 'conv' as const, members: conversationMembers }, - { regex: /\bflow\.(\w+)/g, type: 'flow' as const, members: flowMembers } + { regex: /conv\.functions\.(\w+)/g, type: 'conv' as const }, + { regex: /flow\.functions\.(\w+)/g, type: 'flow' as const } ]; for (let i = 0; i < lineCount; i++) { const line = document.lineAt(i).text; - if (!line.includes('conv.') && !line.includes('flow.')) continue; + if (!line.includes('.functions.')) continue; for (const pattern of patterns) { let match; pattern.regex.lastIndex = 0; while ((match = pattern.regex.exec(line)) !== null) { - const attr = match[1]; - if (attr === 'functions' || pattern.members[attr]) continue; + const functionName = match[1]; const location = pattern.type === 'conv' - ? PythonFunctionResolver.resolveConvFunction(attr, document) - : PythonFunctionResolver.resolveFlowFunction(attr, document); + ? PythonFunctionResolver.resolveConvFunction(functionName, document) + : PythonFunctionResolver.resolveFlowFunction(functionName, document); if (location) { - const dotIndex = match[0].indexOf('.'); - const attrStart = match.index + dotIndex + 1; + const fnStart = match.index + match[0].indexOf(functionName); const range = new vscode.Range( - new vscode.Position(i, attrStart), - new vscode.Position(i, attrStart + attr.length) + new vscode.Position(i, fnStart), + new vscode.Position(i, fnStart + functionName.length) ); links.push(new vscode.DocumentLink(range, location.uri)); } From 12ca90a8d9878ce469ce8d960401c04e7774d89a Mon Sep 17 00:00:00 2001 From: Dillon Date: Fri, 12 Jun 2026 11:01:40 -0700 Subject: [PATCH 4/4] fix: resolve flow functions from flows/flow_name/functions/ path The resolver was looking for flow functions at project_root/functions/flow_name/func.py but the actual structure is project_root/flows/flow_name/functions/func.py. Now uses the flow_config.yaml ancestor to locate the flow directory and looks in its functions/ subdirectory. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/pythonFunctionResolver.ts | 43 ++++++++++++----------------------- src/pythonLanguageFeatures.ts | 16 ++++++------- 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/src/pythonFunctionResolver.ts b/src/pythonFunctionResolver.ts index a5c3baac..d2dc3d32 100644 --- a/src/pythonFunctionResolver.ts +++ b/src/pythonFunctionResolver.ts @@ -117,33 +117,24 @@ export class PythonFunctionResolver { } /** - * Resolves flow.functions.function_name() to the flow function file path - * Flow functions are located at: project_root/functions/flow_name/function_name.py - * The flow is determined by finding which flow the current file belongs to + * Resolves flow.functions.function_name() to the flow function file path. + * Flow functions are located at: flows/flow_name/functions/function_name.py + * The flow is determined by finding the flow_config.yaml ancestor of the current file. */ static resolveFlowFunction(functionName: string, document: vscode.TextDocument): vscode.Location | null { debugLog('Resolving flow function:', functionName, 'for file:', document.uri.fsPath); - - const projectRoot = this.findProjectRoot(document.uri.fsPath); - debugLog('Project root found:', projectRoot); - - if (!projectRoot) { - debugLog('No project root found'); - return null; - } - const flowName = this.getFlowName(document.uri.fsPath); - debugLog('Flow name:', flowName); - - if (!flowName) { - debugLog('No flow name found'); + const flowDir = this.findFlowDirectory(document.uri.fsPath); + debugLog('Flow directory found:', flowDir); + + if (!flowDir) { + debugLog('No flow directory found'); return null; } - // Flow functions are in project_root/functions/flow_name/function_name.py - const functionPath = path.join(projectRoot, 'functions', flowName, `${functionName}.py`); + const functionPath = path.join(flowDir, 'functions', `${functionName}.py`); debugLog('Looking for flow function at:', functionPath, 'exists:', fs.existsSync(functionPath)); - + if (fs.existsSync(functionPath)) { return new vscode.Location( vscode.Uri.file(functionPath), @@ -176,21 +167,15 @@ export class PythonFunctionResolver { /** * Gets all available flow function names for the current file's flow - * Flow functions are located at: project_root/functions/flow_name/ + * Flow functions are located at: flows/flow_name/functions/ */ static getFlowFunctionNames(document: vscode.TextDocument): string[] { - const projectRoot = this.findProjectRoot(document.uri.fsPath); - if (!projectRoot) { - return []; - } - - const flowName = this.getFlowName(document.uri.fsPath); - if (!flowName) { + const flowDir = this.findFlowDirectory(document.uri.fsPath); + if (!flowDir) { return []; } - // Flow functions are in project_root/functions/flow_name/ - const functionsDir = path.join(projectRoot, 'functions', flowName); + const functionsDir = path.join(flowDir, 'functions'); if (!fs.existsSync(functionsDir)) { return []; } diff --git a/src/pythonLanguageFeatures.ts b/src/pythonLanguageFeatures.ts index 12145e7c..6c68f2f4 100644 --- a/src/pythonLanguageFeatures.ts +++ b/src/pythonLanguageFeatures.ts @@ -464,7 +464,7 @@ export class PythonCompletionProvider implements vscode.CompletionItemProvider { function getFunctionInfoFromFile(filePath: string): { functionName: string; type: 'conv' | 'flow' } | null { const fileName = path.basename(filePath, '.py'); const dirName = path.dirname(filePath); - + // Check if this is a global function (in project_root/functions/function_name.py) const projectRoot = PythonFunctionResolver.findProjectRoot(filePath); if (projectRoot) { @@ -472,16 +472,16 @@ function getFunctionInfoFromFile(filePath: string): { functionName: string; type if (dirName === globalFunctionsDir) { return { functionName: fileName, type: 'conv' }; } - - // Check if this is a flow function (in project_root/functions/flow_name/function_name.py) - const relativePath = path.relative(globalFunctionsDir, dirName); - const parts = relativePath.split(path.sep); - if (parts.length === 1 && parts[0] && parts[0] !== '.') { - // We're in functions/flow_name/, so this is a flow function + } + + // Check if this is a flow function (in flows/flow_name/functions/function_name.py) + if (path.basename(dirName) === 'functions') { + const flowDir = path.dirname(dirName); + if (fs.existsSync(path.join(flowDir, 'flow_config.yaml'))) { return { functionName: fileName, type: 'flow' }; } } - + return null; }