Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve included method detection #554

Merged
merged 24 commits into from
Nov 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions .vscode/dev.code-snippets
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,12 @@
"scope": "javascript,typescript",
"prefix": "suite",
"body": [
"suite('$1', () => {",
"suite($1.name, () => {",
" const tests: [",
" name: string,",
" args: Parameters<typeof $1>,",
" expected: ReturnType<typeof $1>,",
" ][] = [$0];",
" ][] = [[$0]];",

" tests.forEach(([name, args, expected]) =>",
" test(name, () => assert.strictEqual($1(...args), expected)),",
Expand Down
2 changes: 1 addition & 1 deletion ahk2
Submodule ahk2 updated from c05e25 to 22d94d
6 changes: 6 additions & 0 deletions changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 6.4.0 - unreleased

### New features

- Hovering over a filename in an `#include` directive now provides a link to that document in your IDE

## 6.3.0 - 2024-10-19 🕳️

### New features
Expand Down
5 changes: 2 additions & 3 deletions src/common/codeUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ export class CodeUtil {
}

/**
* Concats an array and an item or array of items. Impure, @see array is modified
* Concats an array and an item or array of items. Impure, `array` is modified
* @param array The initial array
* @param items Either an item to add to the end of the array,
* or another array to concat to the end of @see array
* or another array to concat to the end of `array`
*/
public static join(array: unknown[], items: unknown) {
if (!array || !items) {
Expand Down Expand Up @@ -54,7 +54,6 @@ export class CodeUtil {
}

/** Whether the current active text editor is for an AHK v1 file */
// todo use LSP for all v2 functionality
export const isV1 = (): boolean =>
vscode.window.activeTextEditor?.document.languageId === LanguageId.ahk1;

Expand Down
5 changes: 5 additions & 0 deletions src/common/out.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import * as vscode from 'vscode';
export class Out {
private static outputChannel: vscode.OutputChannel;

/**
* Logs the given value without focusing the output view.
* Prepends all logs with `new Date().toISOString()`.
*/
public static debug(value: Error | string) {
Out.log(value, false);
}
Expand All @@ -12,6 +16,7 @@ export class Out {
* Logs the given value. Traces errors to console before logging.
* Prepends all logs with `new Date().toISOString()`.
* @param value The value to log
* @param focus whether to focus the output view. Defaults to true.
*/
public static log(value: Error | string, focus = true) {
if (value instanceof Error) {
Expand Down
17 changes: 14 additions & 3 deletions src/parser/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export class Parser {
document: vscode.TextDocument,
options: BuildScriptOptions = {},
): Promise<Script> {
const funcName = 'buildScript';
if (options.usingCache && documentCache.get(document.uri.path)) {
return documentCache.get(document.uri.path);
}
Expand Down Expand Up @@ -135,22 +136,32 @@ export class Parser {
}
}
const script: Script = { methods, labels, refs, variables, blocks };
Out.debug(`${funcName} document.uri.path: ${document.uri.path}`);
Out.debug(`${funcName} script: ${JSON.stringify(script)}`);
documentCache.set(document.uri.path, script);
return script;
}

/**
* Finds the best reference to the method.
* If a method of this name exists in the current file, returns that method.
* Otherwise, searches through document cache to find the matching method.
* Matches are not case-sensitive and only need to match method name.
*/
public static async getMethodByName(
document: vscode.TextDocument,
name: string,
localCache = documentCache,
) {
name = name.toLowerCase();
for (const method of documentCache.get(document.uri.path).methods) {
for (const method of localCache.get(document.uri.path).methods) {
if (method.name.toLowerCase() === name) {
return method;
}
}
for (const filePath of documentCache.keys()) {
for (const method of documentCache.get(filePath).methods) {
// todo this should prioritize included files first.
for (const filePath of localCache.keys()) {
for (const method of localCache.get(filePath).methods) {
if (method.name.toLowerCase() === name) {
return method;
}
Expand Down
67 changes: 34 additions & 33 deletions src/providers/defProvider.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import * as vscode from 'vscode';
import { Parser } from '../parser/parser';
import { existsSync } from 'fs';
import { resolveIncludedPath } from './defProvider.utils';
import { Out } from 'src/common/out';
import { stat } from 'fs/promises';

export class DefProvider implements vscode.DefinitionProvider {
public async provideDefinition(
document: vscode.TextDocument,
position: vscode.Position,
): Promise<vscode.Location | vscode.Location[] | vscode.LocationLink[]> {
const fileLink = await this.tryGetFileLink(document, position);
const fileLink = await tryGetFileLink(document, position);
if (fileLink) {
return fileLink;
}
Expand Down Expand Up @@ -84,36 +86,35 @@ export class DefProvider implements vscode.DefinitionProvider {

return null;
}
}

public async tryGetFileLink(
document: vscode.TextDocument,
position: vscode.Position,
workFolder?: string,
): Promise<vscode.Location> | undefined {
const { text } = document.lineAt(position.line);
const includeMatch = text.match(/(?<=#include).+?\.(ahk|ext)\b/i);
if (!includeMatch) {
return undefined;
}
const parent = workFolder
? workFolder
: document.uri.path.substr(0, document.uri.path.lastIndexOf('/'));
const targetPath = vscode.Uri.file(
includeMatch[0]
.trim()
.replace(/(%A_ScriptDir%|%A_WorkingDir%)/, parent)
.replace(/(%A_LineFile%)/, document.uri.path),
);
if (existsSync(targetPath.fsPath)) {
return new vscode.Location(targetPath, new vscode.Position(0, 0));
} else if (workFolder) {
return this.tryGetFileLink(
document,
position,
vscode.workspace.workspaceFolders?.[0].uri.fsPath,
);
} else {
return undefined;
}
}
//* Utilities requiring the vscode API

/**
* If the position is on an `#Include` line
* and the included path is an existing file,
* returns a Location at the beginning of the included file.
*
* Otherwise returns undefined.
*
** Currently assumes the working directory is the script path and
* does not respect previous `#include dir` directives
*/
async function tryGetFileLink(
document: vscode.TextDocument,
position: vscode.Position,
): Promise<vscode.Location> | undefined {
/** @example '/c:/path/to/file.ahk' */
const docPath = document.uri.path;
const { text } = document.lineAt(position.line);
/** @example 'c:/path/to/included.ahk' */
const resolvedPath = resolveIncludedPath(docPath, text);
Out.debug(`resolvedPath: ${resolvedPath}`);
const fsStat = await stat(resolvedPath);
return fsStat.isFile()
? new vscode.Location(
vscode.Uri.file(resolvedPath),
new vscode.Position(0, 0),
)
: undefined;
}
57 changes: 57 additions & 0 deletions src/providers/defProvider.utils.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { suite, test } from 'mocha';
import assert from 'assert';
import { getIncludedPath, resolveIncludedPath } from './defProvider.utils';

suite(getIncludedPath.name, () => {
const tests: [
name: string,
args: Parameters<typeof getIncludedPath>,
expected: ReturnType<typeof getIncludedPath>,
][] = [
['comma', ['#include , a b.ahk'], 'a b.ahk'],
['no hash', ['include , a b.ahk'], undefined],
[
'no comma nor extra space',
['#include path/to/file.ahk'],
'path/to/file.ahk',
],
['ah1', ['#include a.ah1'], 'a.ah1'],
['ahk1', ['#include a.ahk1'], 'a.ahk1'],
['ext', ['#include a.ext'], 'a.ext'],
['preceding whitespace', [' #include a.ahk'], 'a.ahk'],
['directory', ['#include a'], 'a'],
['non-whitespace preceding char', [';#include a'], undefined],
['escaped `;` with whitespace', ['#include a `;b.ahk'], 'a `;b.ahk'],
['escaped `;` without whitespace', ['#include a`;b.ahk'], 'a`;b.ahk'],
['unescaped `;` without whitespace', ['#include a;b.ahk'], 'a;b.ahk'],
['unescaped `;` with whitespace', ['#include a ;b.ahk'], 'a'],
['unescaped valid `%`', ['#include %A_ScriptDir%'], '%A_ScriptDir%'],
['unescaped `<` and `>`', ['#include <foo>'], '<foo>'],
];
tests.forEach(([name, args, expected]) =>
test(name, () =>
assert.strictEqual(getIncludedPath(...args), expected),
),
);
});

suite(resolveIncludedPath.name, () => {
const tests: [
name: string,
args: Parameters<typeof resolveIncludedPath>,
expected: ReturnType<typeof resolveIncludedPath>,
][] = [
['relative file', ['/c:/main.ahk', 'a.ahk'], 'c:\\a.ahk'],
['absolute file', ['/c:/users/main.ahk', 'd:/b.ahk'], 'd:\\b.ahk'],
['with single dot', ['/c:/main.ahk', './c.ahk'], 'c:\\c.ahk'],
['with double dot', ['/c:/users/main.ahk', '../d.ahk'], 'c:\\d.ahk'],
];
tests.forEach(([name, args, expected]) =>
test(name, () =>
assert.strictEqual(
resolveIncludedPath(args[0], `#include ${args[1]}`),
expected,
),
),
);
});
69 changes: 69 additions & 0 deletions src/providers/defProvider.utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//* Utilities not requiring the vscode API

import { isAbsolute, join, normalize } from 'path';

/**
** Returns the string representing the included path after the `#include`.
** Only works for actual `#include` directives, not comments or strings containing `#include`.
** Does not resolve or normalize the included path.
* @example
* getIncludedPath('#include , a b.ahk') === 'a b.ahk'
* getIncludedPath(' #include path/to/file.ahk') === 'path/to/file.ahk'
* getIncludedPath('include , a b.ahk') === undefined // no `#`
* getIncludedPath('; #include , a b.ahk') === undefined
* getIncludedPath('x := % "#include , a b.ahk"') === undefined
* getIncludedPath('#include a') === 'a'
* getIncludedPath('#include %A_ScriptDir%') === '%A_ScriptDir%'
* getIncludedPath('#include <myLib>') === '<myLib>'
* getIncludedPath('#include semi-colon ;and-more.ahk') === 'semi-colon'
* getIncludedPath('#include semi-colon`;and-more.ahk') === 'semi-colon`;and-more.ahk'
*/
export const getIncludedPath = (ahkLine: string): string | undefined =>
ahkLine.match(/^\s*#include\s*,?\s*(.+?)( ;.*)?$/i)?.[1];

const normalizeIncludedPath = (
includedPath: string,
basePath: string,
parentGoodPath: string,
): string =>
normalize(
includedPath
.trim()
.replace(/`;/g, ';') // only semi-colons are escaped
.replace(/(%A_ScriptDir%|%A_WorkingDir%)/, parentGoodPath)
.replace(/(%A_LineFile%)/, basePath),
);

/**
* Returns the absolute, normalized path included by a #include directive.
* Does not check if that path is a to a folder, or if that path exists.
*
* @param basePath
* The path to include from, usually the script's path.
*
* This may be a different path if the including script has a preceding `#include dir`
*
* @param includedPath The path that's included in the `#include` directive
*/
export const resolveIncludedPath = (
/**
* The path of the current script, namely `vscode.document.uri.path`:
* @example '/c:/path/to/file.ahk'
*/
basePath: string,
/** Line of text from the including script. */
ahkLine: string,
): string | undefined => {
const includedPath = getIncludedPath(ahkLine);
/** @example 'c:/path/to' */
const parentGoodPath = basePath.substring(1, basePath.lastIndexOf('/'));
const normalizedPath = normalizeIncludedPath(
includedPath,
basePath,
parentGoodPath,
);
const absolutePath = isAbsolute(includedPath)
? normalize(includedPath)
: join(parentGoodPath, normalizedPath);
return absolutePath;
};