Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
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';

Expand Down Expand Up @@ -124,7 +124,7 @@
openLabel: 'Select Flow Directory',
defaultUri: vscode.Uri.file(workspaceRoot)
});
if (!selectedFolders || selectedFolders.length === 0) return;

Check warning on line 127 in src/extension.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
flowDir = selectedFolders[0].fsPath;
}
} else {
Expand All @@ -134,7 +134,7 @@
canSelectMany: false,
openLabel: 'Select Flow Directory'
});
if (!selectedFolders || selectedFolders.length === 0) return;

Check warning on line 137 in src/extension.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
flowDir = selectedFolders[0].fsPath;
}
}
Expand All @@ -148,7 +148,7 @@
placeHolder: 'e.g. My Flow',
validateInput: (value) => {
const trimmed = value.trim();
if (!trimmed) return 'Name is required';

Check warning on line 151 in src/extension.ts

View workflow job for this annotation

GitHub Actions / build

Expected { after 'if' condition
if (/[<>:"/\\|?*]/.test(trimmed)) return 'Name cannot contain \\ / : * ? " < > |';
return undefined;
}
Expand Down Expand Up @@ -248,6 +248,13 @@
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
Expand All @@ -263,7 +270,8 @@
pythonDefinitionProvider,
pythonHoverProvider,
pythonReferencesProvider,
pythonCompletionProvider
pythonCompletionProvider,
pythonFunctionLinkProvider
);
}

Expand Down
43 changes: 14 additions & 29 deletions src/pythonFunctionResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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 [];
}
Expand Down
93 changes: 71 additions & 22 deletions src/pythonLanguageFeatures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,14 @@ function extractFunctionCall(
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
Expand All @@ -61,19 +61,19 @@ function extractFunctionCall(
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) {
Expand Down Expand Up @@ -464,24 +464,24 @@ 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) {
const globalFunctionsDir = path.join(projectRoot, 'functions');
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;
}

Expand All @@ -496,21 +496,21 @@ async function findFunctionReferences(
token: vscode.CancellationToken
): Promise<vscode.Location[]> {
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}`);

try {
Expand Down Expand Up @@ -547,30 +547,30 @@ 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)) {
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(
Expand Down Expand Up @@ -630,3 +630,52 @@ export class PythonReferencesProvider implements vscode.ReferenceProvider {
}
}

/**
* 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.
*/
export class PythonFunctionLinkProvider implements vscode.DocumentLinkProvider {
provideDocumentLinks(
document: vscode.TextDocument,
_token: vscode.CancellationToken
): vscode.ProviderResult<vscode.DocumentLink[]> {
const links: vscode.DocumentLink[] = [];
const lineCount = document.lineCount;

const patterns = [
{ 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('.functions.')) continue;

for (const pattern of patterns) {
let match;
pattern.regex.lastIndex = 0;
while ((match = pattern.regex.exec(line)) !== null) {
const functionName = match[1];

const location = pattern.type === 'conv'
? PythonFunctionResolver.resolveConvFunction(functionName, document)
: PythonFunctionResolver.resolveFlowFunction(functionName, document);

if (location) {
const fnStart = match.index + match[0].indexOf(functionName);
const range = new vscode.Range(
new vscode.Position(i, fnStart),
new vscode.Position(i, fnStart + functionName.length)
);
links.push(new vscode.DocumentLink(range, location.uri));
}
}
}
}

return links;
}
}

Loading