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

fixes for vanilla twig and import completions #59

Merged
merged 15 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from 14 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
19 changes: 9 additions & 10 deletions packages/language-server/__tests__/completion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,8 @@ import Parser from 'web-tree-sitter';
import { twigFilters } from '../src/staticCompletionInfo';
import { localVariables } from '../src/completions/local-variables';
import { CompletionItemKind, type CompletionItem } from 'vscode-languageserver';
import { documentFromCode, documentFromCodeWithTypeResolver, initializeTestParser } from './utils';
import { createDocumentCache, documentFromCode, documentFromCodeWithTypeResolver, initializeTestParser } from './utils';
import { variableProperties } from '../src/completions/variableProperties';
import { DocumentCache } from '../src/documents/DocumentCache';
import { MockEnvironment, MockPhpExecutor } from './mocks';
import { ExpressionTypeResolver } from '../src/typing/ExpressionTypeResolver';
import { TypeResolver } from '../src/typing/TypeResolver';
Expand Down Expand Up @@ -183,7 +182,7 @@ describe('completion', () => {
'components.html.twig',
);

const documentCache = new DocumentCache({ name: '', uri: '' });
const documentCache = createDocumentCache();
documentCache.configure(MockEnvironment, null);
await documentCache.updateText(documentWithMacroUsage.uri, documentWithMacroUsage.text);
await documentCache.updateText(importedDocument.uri, importedDocument.text);
Expand Down Expand Up @@ -221,7 +220,7 @@ describe('completion', () => {
'components.html.twig',
);

const documentCache = new DocumentCache({ name: '', uri: '' });
const documentCache = createDocumentCache();
documentCache.configure(MockEnvironment, null);
await documentCache.updateText(documentWithMacroUsage.uri, documentWithMacroUsage.text);
await documentCache.updateText(importedDocument.uri, importedDocument.text);
Expand Down Expand Up @@ -252,7 +251,7 @@ describe('completion', () => {
typeResolver,
);

const documentCache = new DocumentCache({ name: '', uri: '' });
const documentCache = createDocumentCache();
documentCache.configure(MockEnvironment, typeResolver);

const pos = {
Expand Down Expand Up @@ -280,7 +279,7 @@ describe('completion', () => {
typeResolver,
);

const documentCache = new DocumentCache({ name: '', uri: '' });
const documentCache = createDocumentCache();
documentCache.configure(MockEnvironment, typeResolver);

const pos = {
Expand Down Expand Up @@ -308,7 +307,7 @@ describe('completion', () => {
typeResolver,
);

const documentCache = new DocumentCache({ name: '', uri: '' });
const documentCache = createDocumentCache();
documentCache.configure(MockEnvironment, typeResolver);

const pos = {
Expand Down Expand Up @@ -336,7 +335,7 @@ describe('completion', () => {
typeResolver,
);

const documentCache = new DocumentCache({ name: '', uri: '' });
const documentCache = createDocumentCache();
documentCache.configure(MockEnvironment, typeResolver);

const pos = {
Expand Down Expand Up @@ -364,7 +363,7 @@ describe('completion', () => {
typeResolver,
);

const documentCache = new DocumentCache({ name: '', uri: '' });
const documentCache = createDocumentCache();
documentCache.configure(MockEnvironment, typeResolver);

const pos = {
Expand Down Expand Up @@ -393,7 +392,7 @@ describe('completion', () => {
typeResolver,
);

const documentCache = new DocumentCache({ name: '', uri: '' });
const documentCache = createDocumentCache();
documentCache.configure(MockEnvironment, typeResolver);

const pos = {
Expand Down
20 changes: 14 additions & 6 deletions packages/language-server/__tests__/diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,35 @@
import { describe, test, before } from 'node:test'
import * as assert from 'node:assert/strict'
import { createLengthRange, initializeTestParser } from './utils';
import { createDocumentCache, createLengthRange, initializeTestParser } from './utils';
import Parser from 'web-tree-sitter';
import { DiagnosticProvider } from '../src/diagnostics';
import { Document } from 'documents';


describe('diagnostics', () => {
let parser!: Parser;
let diagnosticProvider = new DiagnosticProvider(null as any);

const documentCache = createDocumentCache();

let diagnosticProvider = new DiagnosticProvider(null as any, documentCache);

before(async () => {
parser = await initializeTestParser();
});

const testDiagnostic = (code: string, start = 0, length = code.length) => {
const template = parser.parse(code);
const diagnostics = diagnosticProvider.validateTree(template);
const testDiagnostic = async (code: string, start = 0, length = code.length) => {
const document = new Document('test://test.html.twig');
await documentCache.setText(document, code);

const diagnostics = await diagnosticProvider.validateTree(document);

assert.equal(diagnostics.length, 1);
assert.deepEqual(diagnostics[0].range, createLengthRange(start, length));
};

test('empty output', () => testDiagnostic('{{ }}'));
test('empty output', () => {
testDiagnostic('{{ }}');
});
test('empty if condition', () => testDiagnostic(`{% if %}<input>{% endif %}`, 0, '{% if %}'.length));
test('empty for element', () => testDiagnostic('{% for %}<input>{% endfor %}', 0, '{% for %}'.length));

Expand Down
3 changes: 3 additions & 0 deletions packages/language-server/__tests__/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import * as path from 'path';
import { LocalSymbolCollector } from '../src/symbols/LocalSymbolCollector';
import { MockPhpExecutor } from './mocks';
import { TypeResolver } from '../src/typing/TypeResolver';
import { DocumentCache } from 'documents';

type DocumentWithText = Document & { text: string };

Expand All @@ -29,6 +30,8 @@ export const documentFromCodeWithTypeResolver = async (
return document as DocumentWithText;
};

export const createDocumentCache = () => new DocumentCache({ name: '', uri: 'file:///' })

export const initializeTestParser = async () => {
const wasmPath = path.join(
require.main!.path,
Expand Down
54 changes: 49 additions & 5 deletions packages/language-server/phpUtils/getTwigMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,58 @@

namespace Twiggy\Metadata;

use Twig\Loader\LoaderInterface;

// https://github.com/twigphp/Twig/blob/8f3f8df9cdedc8cbc66d1a75790225a7d44dac67/src/Loader/FilesystemLoader.php#L280
function isAbsolutePath(string $file): bool {
return strspn($file, '/\\', 0, 1)
|| (\strlen($file) > 3 && ctype_alpha($file[0])
&& ':' === $file[1]
&& strspn($file, '/\\', 2, 1)
)
|| null !== parse_url($file, \PHP_URL_SCHEME)
;
}

/**
* Map supported loader namespaces to paths.
* @param array<string,string[]> &$loaderPaths
* @param LoaderInterface $loader Loader.
*/
function mapNamespaces(array &$loaderPaths, LoaderInterface $loader): void {
if ($loader instanceof \Twig\Loader\ChainLoader) {
foreach ($loader->getLoaders() as $subLoader) {
mapNamespaces($loaderPaths, $subLoader);
}

return;
}

if ($loader instanceof \Twig\Loader\FilesystemLoader) {
$namespaces = $loader->getNamespaces();
$rootPath = getcwd() . \DIRECTORY_SEPARATOR;

foreach ($namespaces as $namespace) {
$ns_index = \Twig\Loader\FilesystemLoader::MAIN_NAMESPACE === $namespace
? ''
: ('@' . $namespace);

$loaderPaths[$ns_index] = [];
foreach ($loader->getPaths($namespace) as $path) {
$loaderPaths[$ns_index][] = realpath(
isAbsolutePath($path)
? $path
: $rootPath . $path
);
}
}
}
}

function getTwigMetadata(\Twig\Environment $twig, string $framework = ''): array {
$loaderPathsArray = [];
if ($framework !== 'craft') {
$twigLoader = $twig->getLoader();
$namespaces = $twigLoader->getNamespaces();
foreach ($namespaces as $namespace) {
$loaderPathsArray[$namespace] = $twigLoader->getPaths($namespace);
}
mapNamespaces($loaderPathsArray, $twig->getLoader());
}

$globals = $twig->getGlobals();
Expand Down
46 changes: 36 additions & 10 deletions packages/language-server/src/commands/ExecuteCommandProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,34 +3,60 @@ import { isInsideHtmlRegion } from '../utils/node';
import { DocumentCache } from '../documents';

export enum Command {
DebugCommand = 'twiggy.debug-command',
IsInsideHtmlRegion = 'twiggy.is-inside-html-region',
}

const commands = new Map([
[Command.IsInsideHtmlRegion, isInsideHtmlRegion],
]);

export class ExecuteCommandProvider {
private commands: Map<Command, (...args: any) => Promise<any>>;

constructor(
connection: Connection,
private readonly documentCache: DocumentCache,
) {
this.commands = new Map<Command, (...args: any) => Promise<any>>([
[Command.DebugCommand, this.debugCommand],
[Command.IsInsideHtmlRegion, this.isInsideHtmlRegionCommand],
]);

connection.onExecuteCommand(
this.onExecuteCommand.bind(this),
);
}

async onExecuteCommand(params: ExecuteCommandParams) {
const [ commandName ] = params.command.split('(');
const command = commands.get(commandName as Command);
const command = this.commands.get(params.command as Command);

if (!command || !params.arguments) {
if (
!command ||
!params.arguments ||
params.arguments.length < 1 ||
'string' !== typeof params.arguments[0]
) {
return;
}

const [uri, position] = params.arguments as [DocumentUri, Position];
const document = await this.documentCache.get(uri);
const [ workspaceFolderPath, ...args ] = params.arguments as [string, ...any];
if (workspaceFolderPath !== this.documentCache.workspaceFolderPath) {
moetelo marked this conversation as resolved.
Show resolved Hide resolved
console.warn(
`Attempt to call command ${params.command} from invalid workspace`,
`expected ${this.documentCache.workspaceFolderPath}`,
`got ${workspaceFolderPath}`,
);
return;
}

return command(document, position);
return command(...args);
}

private debugCommand = async (...args: any) =>
console.info("Debug command is called:", args);

private isInsideHtmlRegionCommand = async (
uri: DocumentUri,
position: Position,
) => {
const document = await this.documentCache.get(uri);
return isInsideHtmlRegion(document, position)
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export class CompletionProvider {
...await variableProperties(document, this.documentCache, cursorNode, this.#expressionTypeResolver, params.position),
...await templatePaths(
cursorNode,
params.position,
this.workspaceFolderPath,
this.#environment.templateMappings,
),
Expand Down
46 changes: 31 additions & 15 deletions packages/language-server/src/completions/template-paths.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CompletionItem, CompletionItemKind, DocumentUri } from 'vscode-languageserver';
import { CompletionItem, CompletionItemKind, Position, Range } from 'vscode-languageserver';
import path from 'path';
import { SyntaxNode } from 'web-tree-sitter';
import {
Expand All @@ -7,11 +7,12 @@ import {
} from '../constants/template-usage';
import getTwigFiles from '../utils/getTwigFiles';
import { TemplatePathMapping } from '../twigEnvironment/types';
import { documentUriToFsPath } from '../utils/uri';
import { getStringNodeValue } from 'utils/node';

export async function templatePaths(
cursorNode: SyntaxNode,
workspaceFolderUri: DocumentUri,
position: Position,
workspaceFolderDirectory: string,
templateMappings: TemplatePathMapping[],
): Promise<CompletionItem[]> {
if (cursorNode.type !== 'string') {
Expand Down Expand Up @@ -49,25 +50,40 @@ export async function templatePaths(
node.parent?.childForFieldName('name')?.text || '',
)) ||
// {{ block("title", "common_blocks.twig") }}
(node.type === 'arguments' &&
node.parent?.childForFieldName('name')?.text === 'block' &&
cursorNode?.equals(node.namedChildren[1]))
(node.type === 'arguments'
&& node.parent?.childForFieldName('name')?.text === 'block'
&& node.namedChildren[1]
&& cursorNode?.equals(node.namedChildren[1]))
) {
const workspaceFolderDirectory = documentUriToFsPath(workspaceFolderUri);

const completions: CompletionItem[] = [];

const nodeStringStart = cursorNode.startPosition.column + 1;
const nodeStringEnd = cursorNode.endPosition.column - 1;
const range: Range = {
start: { line: position.line, character: nodeStringStart },
end: { line: position.line, character: nodeStringEnd },
};

const searchText = getStringNodeValue(cursorNode)
.substring(0, position.character - nodeStringStart);

for (const { namespace, directory } of templateMappings) {
const templatesDirectory = path.resolve(workspaceFolderDirectory, directory);
const templatesDirectory = path.resolve(workspaceFolderDirectory, directory)

for (const twigPath of await getTwigFiles(directory)) {
const relativePathToTwigFromTemplatesDirectory = path.relative(templatesDirectory, twigPath);
const includePath = path.join(namespace, relativePathToTwigFromTemplatesDirectory);
const relativePathToTwigFromTemplatesDirectory = path.posix.relative(templatesDirectory, twigPath);
const includePath = path.posix.join(namespace, relativePathToTwigFromTemplatesDirectory)

completions.push({
label: includePath,
kind: CompletionItemKind.File,
});
if (searchText === '' || includePath.startsWith(searchText)) {
completions.push({
label: includePath,
kind: CompletionItemKind.File,
textEdit: {
range,
newText: includePath,
},
});
}
}
}

Expand Down
10 changes: 10 additions & 0 deletions packages/language-server/src/configuration/ConfigurationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
import { TypeResolver } from '../typing/TypeResolver';
import { isFile } from '../utils/files/fileStat';
import { readFile } from 'fs/promises';
import { DiagnosticProvider } from 'diagnostics';

export class ConfigurationManager {
readonly configurationSection = 'twiggy';
Expand All @@ -45,6 +46,7 @@ export class ConfigurationManager {
private readonly signatureHelpProvider: SignatureHelpProvider,
private readonly documentCache: DocumentCache,
private readonly workspaceFolder: WorkspaceFolder,
private readonly diagnosticProvider: DiagnosticProvider,
) {
connection.client.register(DidChangeConfigurationNotification.type, { section: this.configurationSection });
connection.onDidChangeConfiguration(this.onDidChangeConfiguration.bind(this));
Expand Down Expand Up @@ -83,6 +85,13 @@ export class ConfigurationManager {
workspaceDirectory,
});

if (null === twigEnvironment.environment) {
console.warn('Failed to load Twig environment.')
} else {
console.info('Successfully loaded Twig environment.')
console.debug(twigEnvironment.environment)
}

this.applySettings(twigEnvironment, phpExecutor);
}

Expand Down Expand Up @@ -117,6 +126,7 @@ export class ConfigurationManager {

this.definitionProvider.phpExecutor = phpExecutor;
this.completionProvider.refresh(frameworkEnvironment, phpExecutor, typeResolver);
this.diagnosticProvider.refresh(frameworkEnvironment, phpExecutor, typeResolver);
this.signatureHelpProvider.reindex(frameworkEnvironment);
this.documentCache.configure(frameworkEnvironment, typeResolver);
}
Expand Down
Loading