├── packages
├── vscode
│ ├── README.md
│ ├── CHANGELOG.md
│ ├── .vscodeignore
│ ├── assets
│ │ └── logo.png
│ ├── tsconfig.json
│ ├── src
│ │ ├── utils
│ │ │ └── unwrapCompletionArray.ts
│ │ ├── autoInsert.ts
│ │ └── extension.ts
│ ├── languages
│ │ └── twig.configuration.json
│ ├── build
│ │ └── index.mjs
│ └── package.json
├── tree-sitter-twig
│ ├── queries
│ │ ├── highlights.scm
│ │ └── injections.scm
│ ├── .prettierrc
│ ├── README.md
│ ├── .gitignore
│ ├── .gitattributes
│ ├── .editorconfig
│ ├── Cargo.toml
│ ├── package.json
│ ├── binding.gyp
│ ├── tree-sitter.json
│ ├── src
│ │ └── scanner.c
│ └── corpus
│ │ ├── tests.txt
│ │ ├── directives.txt
│ │ └── var-comments.txt
└── language-server
│ ├── src
│ ├── diagnostics
│ │ ├── index.ts
│ │ └── DiagnosticProvider.ts
│ ├── documents
│ │ ├── index.ts
│ │ ├── Document.ts
│ │ └── DocumentCache.ts
│ ├── utils
│ │ ├── uri
│ │ │ ├── index.ts
│ │ │ ├── toDocumentUri.ts
│ │ │ └── documentUriToFsPath.ts
│ │ ├── position
│ │ │ ├── index.ts
│ │ │ ├── pointToPosition.ts
│ │ │ ├── rangeContainsPosition.ts
│ │ │ └── comparePositions.ts
│ │ ├── node
│ │ │ ├── isInsideStatement.ts
│ │ │ ├── getStringNodeValue.ts
│ │ │ ├── isInsideHtmlRegion.ts
│ │ │ ├── findParentByType.ts
│ │ │ ├── getNodeRange.ts
│ │ │ ├── closestByPredicate.ts
│ │ │ ├── index.ts
│ │ │ ├── PreOrderCursorIterator.ts
│ │ │ ├── isBlockIdentifier.ts
│ │ │ ├── isEmptyEmbedded.ts
│ │ │ ├── isPathInsideTemplateEmbedding.ts
│ │ │ └── parseFunctionCall.ts
│ │ ├── files
│ │ │ ├── fileStat.ts
│ │ │ └── resolveTemplate.ts
│ │ ├── parser.ts
│ │ ├── getTwigFiles.ts
│ │ └── exec.ts
│ ├── typing
│ │ ├── ITypeResolver.ts
│ │ ├── IExpressionTypeResolver.ts
│ │ ├── TypeResolver.ts
│ │ └── ExpressionTypeResolver.ts
│ ├── twigEnvironment
│ │ ├── TwigEnvironmentArgs.ts
│ │ ├── index.ts
│ │ ├── PhpUtilPath.ts
│ │ ├── IFrameworkTwigEnvironment.ts
│ │ ├── types.ts
│ │ ├── CraftTwigEnvironment.ts
│ │ ├── symfony
│ │ │ └── parseDebugTwigOutput.ts
│ │ ├── VanillaTwigEnvironment.ts
│ │ └── SymfonyTwigEnvironment.ts
│ ├── completions
│ │ ├── triggerCompletionCommand.ts
│ │ ├── for-loop.ts
│ │ ├── keywords.ts
│ │ ├── global-variables.ts
│ │ ├── filters.ts
│ │ ├── routes.ts
│ │ ├── functions.ts
│ │ ├── local-variables.ts
│ │ ├── snippets.ts
│ │ ├── phpClasses.ts
│ │ ├── template-paths.ts
│ │ ├── CompletionProvider.ts
│ │ └── variableProperties.ts
│ ├── signature-helps
│ │ ├── triggerParameterHintsCommand.ts
│ │ ├── staticSignatureInformation.ts
│ │ ├── SignatureIndex.ts
│ │ └── SignatureHelpProvider.ts
│ ├── constants
│ │ └── template-usage.ts
│ ├── phpInterop
│ │ ├── primitives.ts
│ │ ├── IPhpExecutor.ts
│ │ ├── ReflectedType.ts
│ │ ├── PhpExecutor.ts
│ │ └── TwigCodeStyleFixer.ts
│ ├── customRequests
│ │ ├── IsInsideHtmlRegionRequest.ts
│ │ └── AutoInsertRequest.ts
│ ├── hovers
│ │ ├── global-variables.ts
│ │ ├── functions.ts
│ │ ├── filters.ts
│ │ ├── for-loop.ts
│ │ ├── HoverProvider.ts
│ │ └── local-variables.ts
│ ├── index.ts
│ ├── semantic-tokens
│ │ ├── tokens-provider.ts
│ │ ├── TokenTypeResolver.ts
│ │ └── SemanticTokensProvider.ts
│ ├── configuration
│ │ ├── LanguageServerSettings.ts
│ │ └── ConfigurationManager.ts
│ ├── commands
│ │ └── IsInsideHtmlRegionCommandProvider.ts
│ ├── formatting
│ │ └── FormattingProvider.ts
│ ├── references
│ │ ├── ReferenceProvider.ts
│ │ └── RenameProvider.ts
│ ├── symbols
│ │ ├── SymbolProvider.ts
│ │ ├── types.ts
│ │ ├── nodeToSymbolMapping.ts
│ │ └── LocalSymbolCollector.ts
│ ├── autoInsertions
│ │ └── BracketSpacesInsertionProvider.ts
│ ├── inlayHints
│ │ └── InlayHintProvider.ts
│ ├── server.ts
│ └── definitions
│ │ └── index.ts
│ ├── README.md
│ ├── bin
│ └── server.js
│ ├── phpUtils
│ ├── printTwigEnvironment.php
│ ├── definitionClassPsr4.php
│ ├── printCraftTwigEnvironment.php
│ ├── completeClassPsr4.php
│ ├── reflectType.php
│ └── getTwigMetadata.php
│ ├── tsconfig.json
│ ├── __tests__
│ ├── documents.test.ts
│ ├── diagnostics.test.ts
│ ├── utils.ts
│ ├── mocks.ts
│ └── deepestAt.test.ts
│ └── package.json
├── pnpm-workspace.yaml
├── .gitignore
├── tsconfig.json
├── .vscode
├── tasks.json
└── launch.json
├── package.json
├── .github
└── workflows
│ └── release.yml
└── README.md
/packages/vscode/README.md:
--------------------------------------------------------------------------------
1 | ../../README.md
--------------------------------------------------------------------------------
/packages/vscode/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ../../CHANGELOG.md
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/queries/highlights.scm:
--------------------------------------------------------------------------------
1 | (comment) @comment
2 |
--------------------------------------------------------------------------------
/packages/language-server/src/diagnostics/index.ts:
--------------------------------------------------------------------------------
1 | export * from './DiagnosticProvider';
2 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all"
4 | }
5 |
--------------------------------------------------------------------------------
/packages/vscode/.vscodeignore:
--------------------------------------------------------------------------------
1 | src
2 | build
3 | .vscodeignore
4 | package-lock.json
5 | tsconfig.json
6 |
--------------------------------------------------------------------------------
/packages/vscode/assets/logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/moetelo/twiggy/HEAD/packages/vscode/assets/logo.png
--------------------------------------------------------------------------------
/pnpm-workspace.yaml:
--------------------------------------------------------------------------------
1 | packages:
2 | - packages/*
3 | onlyBuiltDependencies:
4 | - esbuild
5 | - tree-sitter-cli
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules/
2 | packages/vscode/dist
3 | packages/language-server/dist
4 | .vscode/settings.json
5 | *.vsix
6 |
--------------------------------------------------------------------------------
/packages/language-server/src/documents/index.ts:
--------------------------------------------------------------------------------
1 | export { Document } from './Document';
2 | export { DocumentCache } from './DocumentCache';
3 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/README.md:
--------------------------------------------------------------------------------
1 | # tree-sitter-twig
2 |
3 | Twig grammar for [tree-sitter](https://github.com/tree-sitter/tree-sitter).
4 |
--------------------------------------------------------------------------------
/packages/language-server/README.md:
--------------------------------------------------------------------------------
1 | # Twig Language Server
2 |
3 | TypeScript-powered language server for [Twig](https://twig.symfony.com) templates.
4 |
--------------------------------------------------------------------------------
/packages/language-server/bin/server.js:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | const path = require('path');
3 | require(path.resolve(__dirname, '../dist/server.js'));
4 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/queries/injections.scm:
--------------------------------------------------------------------------------
1 | ((content) @injection.content
2 | (#set! injection.language "html")
3 | (#set! injection.combined))
4 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/uri/index.ts:
--------------------------------------------------------------------------------
1 | export { documentUriToFsPath } from './documentUriToFsPath';
2 | export { toDocumentUri } from './toDocumentUri'
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "references": [
3 | {
4 | "path": "packages/language-server"
5 | },
6 | {
7 | "path": "packages/vscode"
8 | }
9 | ],
10 | "files": []
11 | }
12 |
--------------------------------------------------------------------------------
/packages/language-server/src/typing/ITypeResolver.ts:
--------------------------------------------------------------------------------
1 | import { ReflectedType } from '../phpInterop/ReflectedType';
2 |
3 | export interface ITypeResolver {
4 | reflectType(type: string): Promise;
5 | }
6 |
--------------------------------------------------------------------------------
/packages/language-server/src/twigEnvironment/TwigEnvironmentArgs.ts:
--------------------------------------------------------------------------------
1 |
2 | export type TwigEnvironmentArgs = {
3 | symfonyConsolePath: string,
4 | workspaceDirectory: string,
5 | vanillaTwigEnvironmentPath: string,
6 | };
7 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/position/index.ts:
--------------------------------------------------------------------------------
1 | export { comparePositions } from './comparePositions';
2 | export { pointToPosition } from './pointToPosition';
3 | export { rangeContainsPosition } from './rangeContainsPosition';
4 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/triggerCompletionCommand.ts:
--------------------------------------------------------------------------------
1 | import { Command } from 'vscode-languageserver';
2 |
3 | export const triggerCompletion = Command.create(
4 | 'Trigger completion',
5 | 'editor.action.triggerSuggest',
6 | );
7 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | tree-sitter-twig.wasm
3 | build
4 | bindings
5 | src/tree_sitter/parser.h
6 | src/tree_sitter/alloc.h
7 | src/tree_sitter/array.h
8 | src/parser.c
9 | src/node-types.json
10 | src/grammar.json
11 |
--------------------------------------------------------------------------------
/packages/language-server/src/signature-helps/triggerParameterHintsCommand.ts:
--------------------------------------------------------------------------------
1 | import { Command } from 'vscode-languageserver/node';
2 |
3 | export const triggerParameterHints = Command.create(
4 | 'Trigger parameter hints',
5 | 'editor.action.triggerParameterHints',
6 | );
7 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/position/pointToPosition.ts:
--------------------------------------------------------------------------------
1 | import { Position } from 'vscode-languageserver/node';
2 | import { Point } from 'web-tree-sitter';
3 |
4 | export function pointToPosition(point: Point): Position {
5 | return Position.create(point.row, point.column);
6 | }
7 |
--------------------------------------------------------------------------------
/packages/language-server/src/constants/template-usage.ts:
--------------------------------------------------------------------------------
1 |
2 | export const templateUsingFunctions = [
3 | 'include',
4 | 'source',
5 | ];
6 |
7 | export const templateUsingStatements = [
8 | 'import',
9 | 'from',
10 | 'embed',
11 | 'include',
12 | 'extends',
13 | 'use',
14 | ];
15 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/isInsideStatement.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 |
3 | export const isInsideStatement = (node: SyntaxNode) => node.text.startsWith('{%')
4 | && (
5 | node.firstChild?.type === 'embedded_begin'
6 | || node.parent?.firstChild!.type === 'embedded_begin'
7 | );
8 |
--------------------------------------------------------------------------------
/packages/language-server/src/phpInterop/primitives.ts:
--------------------------------------------------------------------------------
1 | export const primitives = new Set([
2 | 'int',
3 | 'float',
4 | 'string',
5 | 'bool',
6 | 'array',
7 | 'object',
8 | 'callable',
9 | 'iterable',
10 | 'null',
11 | 'void',
12 | 'true',
13 | 'false',
14 | 'mixed',
15 | 'never',
16 | ]);
17 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/uri/toDocumentUri.ts:
--------------------------------------------------------------------------------
1 | import { DocumentUri } from 'vscode-languageserver';
2 | import { URI } from 'vscode-uri';
3 |
4 | export function toDocumentUri(path: DocumentUri | string): DocumentUri {
5 | if (path.startsWith('file://')) {
6 | return path;
7 | }
8 |
9 | return URI.file(path).toString();
10 | }
11 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/.gitattributes:
--------------------------------------------------------------------------------
1 | * text eol=lf
2 |
3 | src/*.json linguist-generated
4 | src/parser.c linguist-generated
5 | src/tree_sitter/* linguist-generated
6 |
7 | bindings/** linguist-generated
8 | binding.gyp linguist-generated
9 | setup.py linguist-generated
10 | Makefile linguist-generated
11 | Package.swift linguist-generated
12 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "2.0.0",
3 | "tasks": [
4 | {
5 | "type": "npm",
6 | "script": "dev",
7 | "problemMatcher": "$esbuild-watch",
8 | "isBackground": true,
9 | "presentation": {
10 | "reveal": "never"
11 | },
12 | "group": {
13 | "kind": "build",
14 | "isDefault": true
15 | }
16 | }
17 | ]
18 | }
19 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/getStringNodeValue.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 |
3 | export function getStringNodeValue(stringNode: SyntaxNode) {
4 | if (stringNode.type !== 'string') {
5 | throw new Error('Node is not a string. ' + stringNode.type);
6 | }
7 |
8 | return stringNode.text.slice('"'.length, -'"'.length)
9 | }
10 |
--------------------------------------------------------------------------------
/packages/language-server/src/customRequests/IsInsideHtmlRegionRequest.ts:
--------------------------------------------------------------------------------
1 | import { TextDocumentPositionParams } from 'vscode-languageserver';
2 |
3 | export namespace IsInsideHtmlRegionRequest {
4 | export type ParamsType = TextDocumentPositionParams;
5 | export type ResponseType = boolean;
6 | export type ErrorType = never;
7 | export const type = 'twiggy/isInsideHtmlRegion';
8 | }
9 |
--------------------------------------------------------------------------------
/packages/language-server/src/typing/IExpressionTypeResolver.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 | import { ReflectedType } from '../phpInterop/ReflectedType';
3 | import { LocalSymbolInformation } from '../symbols/types';
4 |
5 | export interface IExpressionTypeResolver {
6 | resolveExpression(expr: SyntaxNode, locals: LocalSymbolInformation): Promise;
7 | }
8 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/isInsideHtmlRegion.ts:
--------------------------------------------------------------------------------
1 | import { Position } from 'vscode-languageserver';
2 | import { Document } from '../../documents';
3 |
4 | export async function isInsideHtmlRegion(
5 | document: Document,
6 | position: Position,
7 | ): Promise {
8 | const node = document.deepestAt(position);
9 | return node?.type === 'content';
10 | }
11 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/files/fileStat.ts:
--------------------------------------------------------------------------------
1 | import { Stats } from 'fs';
2 | import { stat } from 'fs/promises';
3 |
4 | export const fileStat = (fsPath: string): Promise => stat(fsPath).catch(() => null);
5 |
6 | export const isFile = async (fsPath: string): Promise => {
7 | const stats = await fileStat(fsPath);
8 | return stats !== null && stats.isFile();
9 | };
10 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/findParentByType.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 |
3 | export function findParentByType(
4 | cursorNode: SyntaxNode,
5 | type: string
6 | ): SyntaxNode | undefined {
7 | let node = cursorNode;
8 |
9 | while (node.parent) {
10 | if (node.type === type) {
11 | return node;
12 | }
13 | node = node.parent;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/uri/documentUriToFsPath.ts:
--------------------------------------------------------------------------------
1 | import { DocumentUri } from 'vscode-languageserver';
2 | import { URI } from 'vscode-uri';
3 |
4 | export function documentUriToFsPath(documentUri: DocumentUri): string {
5 | if (!DocumentUri.is(documentUri)) return documentUri;
6 |
7 | // valid document uri must have file: scheme
8 | return URI.parse(documentUri, true).fsPath;
9 | }
10 |
--------------------------------------------------------------------------------
/packages/language-server/src/twigEnvironment/index.ts:
--------------------------------------------------------------------------------
1 | export * from './types';
2 | export { EmptyEnvironment } from './IFrameworkTwigEnvironment';
3 | export { CraftTwigEnvironment } from './CraftTwigEnvironment';
4 | export { VanillaTwigEnvironment } from './VanillaTwigEnvironment';
5 | export { SymfonyTwigEnvironment } from './SymfonyTwigEnvironment';
6 | export { IFrameworkTwigEnvironment } from './IFrameworkTwigEnvironment';
7 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/getNodeRange.ts:
--------------------------------------------------------------------------------
1 | import { Range } from 'vscode-languageserver';
2 | import { TreeCursor, SyntaxNode } from 'web-tree-sitter';
3 | import { pointToPosition } from '../position';
4 |
5 | export const getNodeRange = (node: TreeCursor | SyntaxNode): Range => {
6 | return Range.create(
7 | pointToPosition(node.startPosition),
8 | pointToPosition(node.endPosition)
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/position/rangeContainsPosition.ts:
--------------------------------------------------------------------------------
1 | import { Position, Range } from 'vscode-languageserver/node';
2 | import { comparePositions } from './comparePositions';
3 |
4 | export function rangeContainsPosition(
5 | range: Range,
6 | position: Position
7 | ): boolean {
8 | return (
9 | comparePositions(position, range.start) >= 0 &&
10 | comparePositions(position, range.end) <= 0
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain consistent
2 | # coding styles between different editors and IDEs
3 | # editorconfig.org
4 |
5 | root = true
6 |
7 | [*]
8 | end_of_line = lf
9 | charset = utf-8
10 | trim_trailing_whitespace = true
11 | insert_final_newline = true
12 | indent_style = space
13 | indent_size = 2
14 |
15 | [*.{diff,md}]
16 | trim_trailing_whitespace = false
17 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/closestByPredicate.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 |
3 | export function closestByPredicate(
4 | node: SyntaxNode,
5 | predicate: (node: SyntaxNode) => boolean
6 | ): SyntaxNode | null {
7 | if (predicate(node)) {
8 | return node;
9 | }
10 |
11 | if (!node.parent) {
12 | return null;
13 | }
14 |
15 | return closestByPredicate(node.parent, predicate);
16 | }
17 |
--------------------------------------------------------------------------------
/packages/language-server/phpUtils/printTwigEnvironment.php:
--------------------------------------------------------------------------------
1 | {
6 | if (parser) {
7 | return parser;
8 | }
9 |
10 | await Parser.init();
11 | parser = new Parser();
12 |
13 | if (!wasmPath) {
14 | wasmPath = require.resolve('./tree-sitter-twig.wasm');
15 | }
16 |
17 | parser.setLanguage(await Parser.Language.load(wasmPath));
18 |
19 | return parser;
20 | };
21 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/position/comparePositions.ts:
--------------------------------------------------------------------------------
1 | import { Position } from 'vscode-languageserver/node';
2 |
3 | export function comparePositions(a: Position, b: Position): number {
4 | if (a.line < b.line) return -1;
5 | if (a.line > b.line) return 1;
6 |
7 | if (a.character < b.character) return -1;
8 | if (a.character > b.character) return 1;
9 |
10 | return 0;
11 | }
12 |
13 | export function positionsEqual(a: Position, b: Position): boolean {
14 | return comparePositions(a, b) === 0;
15 | }
16 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twiggy-root",
3 | "version": "0.19.1",
4 | "repository": {
5 | "type": "git",
6 | "url": "git+https://github.com/moetelo/twiggy.git"
7 | },
8 | "private": true,
9 | "scripts": {
10 | "build": "pnpm run --dir ./packages/vscode build",
11 | "dev": "pnpm run --dir ./packages/vscode dev",
12 | "build-grammar-wasm": "pnpm run --dir ./packages/tree-sitter-twig build-wasm"
13 | },
14 | "author": "Mikhail Gunin ",
15 | "license": "Mozilla Public License 2.0"
16 | }
17 |
--------------------------------------------------------------------------------
/packages/language-server/src/hovers/global-variables.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 | import { twigGlobalVariables } from '../staticCompletionInfo';
3 | import { Hover } from 'vscode-languageserver';
4 |
5 | export function globalVariables(cursorNode: SyntaxNode): Hover | undefined {
6 | if (cursorNode.type !== 'variable') return;
7 |
8 | const variable = twigGlobalVariables.find(item => item.label === cursorNode.text);
9 |
10 | if (!variable) return;
11 |
12 | return {
13 | contents: variable?.documentation,
14 | };
15 | }
16 |
--------------------------------------------------------------------------------
/packages/language-server/src/twigEnvironment/PhpUtilPath.ts:
--------------------------------------------------------------------------------
1 | import path from 'node:path';
2 |
3 | export const PhpUtilPath = {
4 | getCraftTwig: path.resolve(__dirname, './phpUtils/printCraftTwigEnvironment.php'),
5 | printTwigEnvironment: path.resolve(__dirname, './phpUtils/printTwigEnvironment.php'),
6 | getDefinitionPhp: path.resolve(__dirname, './phpUtils/definitionClassPsr4.php'),
7 | getCompletionPhp: path.resolve(__dirname, './phpUtils/completeClassPsr4.php'),
8 | reflectType: path.resolve(__dirname, './phpUtils/reflectType.php'),
9 | } as const;
10 |
--------------------------------------------------------------------------------
/packages/vscode/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "module": "commonjs",
4 | "target": "ES2020",
5 | "outDir": "dist",
6 | "lib": [
7 | "ES2020"
8 | ],
9 | "sourceMap": true,
10 | "rootDir": "src",
11 | "strict": true,
12 | "paths": {
13 | "@server": [
14 | "../language-server/src/"
15 | ],
16 | "@tree-sitter-twig": [
17 | "../tree-sitter-twig"
18 | ]
19 | },
20 | },
21 | "references": [
22 | {
23 | "path": "../language-server/tsconfig.json"
24 | }
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/packages/language-server/src/customRequests/AutoInsertRequest.ts:
--------------------------------------------------------------------------------
1 | import { Range, TextDocumentPositionParams, TextEdit } from 'vscode-languageserver';
2 |
3 | export namespace AutoInsertRequest {
4 | export type ParamsType = TextDocumentPositionParams & {
5 | options: {
6 | lastChange: {
7 | range: Range;
8 | rangeOffset: number;
9 | rangeLength: number;
10 | text: string;
11 | },
12 | },
13 | };
14 | export type ResponseType = string | TextEdit | null | undefined;
15 | export type ErrorType = never;
16 | export const type = 'twiggy/client/autoInsert';
17 | }
18 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/index.ts:
--------------------------------------------------------------------------------
1 | export { getNodeRange } from './getNodeRange';
2 | export { getStringNodeValue } from './getStringNodeValue';
3 | export { closestByPredicate } from './closestByPredicate';
4 | export * from './isEmptyEmbedded';
5 | export { findParentByType } from './findParentByType';
6 | export { isInsideHtmlRegion } from './isInsideHtmlRegion';
7 | export { PreOrderCursorIterator } from './PreOrderCursorIterator';
8 | export { isBlockIdentifier } from './isBlockIdentifier';
9 | export { isPathInsideTemplateEmbedding } from './isPathInsideTemplateEmbedding';
10 |
--------------------------------------------------------------------------------
/packages/language-server/src/phpInterop/IPhpExecutor.ts:
--------------------------------------------------------------------------------
1 | import { ReflectedType } from './ReflectedType';
2 |
3 | export interface IPhpExecutor {
4 | call(command: string, args: string[]): Promise<{
5 | stdout: string;
6 | stderr: string;
7 | } | null>;
8 |
9 | callJson(command: string, args: string[]): Promise;
10 | getClassDefinition(className: string): Promise<{ path: string | null; } | null>;
11 | getClassCompletion(className: string): Promise;
12 | reflectType(className: string): Promise;
13 | }
14 |
--------------------------------------------------------------------------------
/packages/language-server/phpUtils/definitionClassPsr4.php:
--------------------------------------------------------------------------------
1 | findFile($CLASS_PSR4) ?: null;
18 |
19 | $result = [
20 | 'path' => $filePath,
21 | ];
22 |
23 | echo json_encode($result, JSON_PRETTY_PRINT) . PHP_EOL;
24 |
--------------------------------------------------------------------------------
/packages/language-server/src/hovers/functions.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 | import { twigFunctions } from '../staticCompletionInfo';
3 | import { Hover } from 'vscode-languageserver';
4 |
5 | const nodeTypes = [ 'variable', 'function' ];
6 |
7 | export function functions(cursorNode: SyntaxNode): Hover | undefined {
8 | if (!nodeTypes.includes(cursorNode.type)) return;
9 |
10 | const variable = twigFunctions.find(item => item.label === cursorNode.text);
11 |
12 | if (!variable?.documentation) return;
13 |
14 | return {
15 | contents: variable.documentation,
16 | };
17 | }
18 |
--------------------------------------------------------------------------------
/packages/language-server/src/phpInterop/ReflectedType.ts:
--------------------------------------------------------------------------------
1 | type Property = {
2 | name: string;
3 | type: string;
4 | };
5 |
6 | type MethodParam = {
7 | name: string;
8 | type: string;
9 | isOptional: boolean;
10 | isVariadic: boolean;
11 | };
12 |
13 | type Method = {
14 | name: string;
15 | type: string;
16 | parameters: MethodParam[];
17 | };
18 |
19 | export type ArrayType = {
20 | itemType: string;
21 | itemReflectedType: ReflectedType | null;
22 | };
23 |
24 | export type ReflectedType = {
25 | properties: Property[];
26 | methods: Method[];
27 | arrayType?: ArrayType;
28 | };
29 |
--------------------------------------------------------------------------------
/packages/language-server/src/index.ts:
--------------------------------------------------------------------------------
1 | import { createConnection, ProposedFeatures } from 'vscode-languageserver/node';
2 | import { Server } from './server';
3 |
4 | const connection = createConnection(ProposedFeatures.all);
5 |
6 | declare const __DEBUG__: boolean;
7 |
8 | if (!__DEBUG__) {
9 | console.log = connection.console.log.bind(connection.console);
10 | console.info = connection.console.info.bind(connection.console);
11 | console.warn = connection.console.warn.bind(connection.console);
12 | console.error = connection.console.error.bind(connection.console);
13 | }
14 |
15 | new Server(connection);
16 |
17 | connection.listen();
18 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/Cargo.toml:
--------------------------------------------------------------------------------
1 | [package]
2 | name = "tree-sitter-twig"
3 | description = "twig grammar for the tree-sitter parsing library"
4 | version = "0.0.1"
5 | keywords = ["incremental", "parsing", "twig"]
6 | categories = ["parsing", "text-editors"]
7 | repository = "https://github.com/tree-sitter/tree-sitter-twig"
8 | edition = "2018"
9 | license = "MIT"
10 |
11 | build = "bindings/rust/build.rs"
12 | include = [
13 | "bindings/rust/*",
14 | "grammar.js",
15 | "queries/*",
16 | "src/*",
17 | ]
18 |
19 | [lib]
20 | path = "bindings/rust/lib.rs"
21 |
22 | [dependencies]
23 | tree-sitter = "~0.20.10"
24 |
25 | [build-dependencies]
26 | cc = "1.0"
27 |
--------------------------------------------------------------------------------
/packages/language-server/src/twigEnvironment/IFrameworkTwigEnvironment.ts:
--------------------------------------------------------------------------------
1 | import { RouteNameToPathRecord, TemplatePathMapping, TwigEnvironment } from './types';
2 |
3 | export interface IFrameworkTwigEnvironment {
4 | get environment(): TwigEnvironment | null;
5 | get routes(): RouteNameToPathRecord;
6 | get templateMappings(): TemplatePathMapping[];
7 | }
8 |
9 | const defaultPathMappings: TemplatePathMapping[] = [
10 | { namespace: '', directory: 'templates' },
11 | ];
12 |
13 | export const EmptyEnvironment: IFrameworkTwigEnvironment = Object.freeze({
14 | environment: null,
15 | routes: {},
16 | templateMappings: defaultPathMappings,
17 | });
18 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/PreOrderCursorIterator.ts:
--------------------------------------------------------------------------------
1 | import { TreeCursor } from 'web-tree-sitter';
2 |
3 | export class PreOrderCursorIterator {
4 | constructor(private readonly cursor: TreeCursor) {
5 | }
6 |
7 | public *[Symbol.iterator](): Generator {
8 | const constructor = this.constructor as typeof PreOrderCursorIterator;
9 |
10 | yield this.cursor;
11 |
12 | if (this.cursor.gotoFirstChild()) {
13 | yield* new constructor(this.cursor);
14 |
15 | while (this.cursor.gotoNextSibling()) {
16 | yield* new constructor(this.cursor);
17 | }
18 |
19 | this.cursor.gotoParent();
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/packages/vscode/src/utils/unwrapCompletionArray.ts:
--------------------------------------------------------------------------------
1 | import { CompletionItem, CompletionList, ProviderResult } from 'vscode';
2 |
3 | export const unwrapCompletionArray = async (
4 | completionProviderResult: ProviderResult,
5 | ): Promise => {
6 | const completionResult = await Promise.resolve(completionProviderResult);
7 |
8 | if (!completionResult) {
9 | return [];
10 | }
11 |
12 | if (Array.isArray(completionResult)) {
13 | return completionResult;
14 | }
15 |
16 | if ('items' in completionResult) {
17 | return completionResult.items;
18 | }
19 |
20 | return [];
21 | };
22 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "tree-sitter-twig",
3 | "version": "0.4.0",
4 | "description": "Twig grammar for tree-sitter",
5 | "main": "bindings/node",
6 | "scripts": {
7 | "build": "pnpx tree-sitter-cli generate --abi=14 && rm -f ./tree-sitter-twig.wasm",
8 | "build-wasm": "pnpm build && pnpx tree-sitter-cli build -w",
9 | "test": "tree-sitter test"
10 | },
11 | "license": "Mozilla Public License 2.0",
12 | "dependencies": {
13 | "node-addon-api": "^8.2.2"
14 | },
15 | "devDependencies": {
16 | "tree-sitter-cli": "0.25.3"
17 | },
18 | "files": [
19 | "README.md",
20 | "LICENSE",
21 | "tree-sitter-twig.wasm"
22 | ]
23 | }
24 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/isBlockIdentifier.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from "web-tree-sitter";
2 | import { parseFunctionCall } from "./parseFunctionCall";
3 |
4 | export const isBlockIdentifier = (node: SyntaxNode): boolean => {
5 | if (!node.parent) {
6 | return false;
7 | }
8 |
9 | if (node.parent.type === 'block' && node.type === 'identifier') {
10 | return true;
11 | }
12 |
13 | if (node.parent.parent?.type === 'call_expression') {
14 | const call = parseFunctionCall(node.parent.parent);
15 | return !!call && node.type === 'string' && call.name === 'block' && !call.object;
16 | }
17 |
18 | return false;
19 | };
20 |
--------------------------------------------------------------------------------
/packages/language-server/src/hovers/filters.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 | import { twigFilters } from '../staticCompletionInfo';
3 | import { Hover } from 'vscode-languageserver';
4 |
5 | const nodeTypes = [ 'variable', 'function' ];
6 |
7 | export function filters(cursorNode: SyntaxNode): Hover | undefined {
8 | if (
9 | !nodeTypes.includes(cursorNode.type)
10 | && cursorNode.previousSibling?.text !== '|'
11 | ) {
12 | return;
13 | }
14 |
15 | const filter = twigFilters.find(item => item.label === cursorNode.text);
16 |
17 | if (!filter?.documentation) return;
18 |
19 | return {
20 | contents: filter.documentation,
21 | };
22 | }
23 |
--------------------------------------------------------------------------------
/packages/language-server/__tests__/documents.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test } from 'node:test'
2 | import * as assert from 'node:assert/strict'
3 | import { generateResolveSequence } from '../src/utils/files/resolveTemplate';
4 |
5 | describe('Documents', () => {
6 | test('resolveTemplate iterates over correct sequence of paths', async () => {
7 | const sequence = [...generateResolveSequence('foo/bar')];
8 |
9 | assert.deepEqual(
10 | sequence,
11 | [
12 | 'foo/bar',
13 | 'foo/bar.twig',
14 | 'foo/bar.html',
15 | 'foo/bar/index.twig',
16 | 'foo/bar/index.html',
17 | ],
18 | );
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/for-loop.ts:
--------------------------------------------------------------------------------
1 | import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node';
2 | import { SyntaxNode } from 'web-tree-sitter';
3 | import { findParentByType } from '../utils/node';
4 | import { isInExpressionScope } from '../utils/node';
5 |
6 | export function forLoop(cursorNode: SyntaxNode): CompletionItem[] {
7 | if (!findParentByType(cursorNode, 'for')) {
8 | return [];
9 | }
10 |
11 | if (cursorNode.type === 'variable' || isInExpressionScope(cursorNode)) {
12 | return [
13 | {
14 | label: 'loop',
15 | kind: CompletionItemKind.Variable,
16 | },
17 | ];
18 | }
19 |
20 | return [];
21 | }
22 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/isEmptyEmbedded.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 |
3 | const outputNodesToNamedChildCount = new Map([
4 | ['output', 1],
5 | ['if', 2],
6 | ['for', 2],
7 | ]);
8 |
9 | export const isEmptyEmbedded = (node: SyntaxNode) => outputNodesToNamedChildCount.has(node.type)
10 | && node.namedChildCount < outputNodesToNamedChildCount.get(node.type)!;
11 |
12 | export const isInExpressionScope = (node: SyntaxNode) => isEmptyEmbedded(node)
13 | // Cursor might be in the `embedded_begin` node.
14 | // TODO: remove `embedded_begin` from the AST?
15 | // or rework this logic to compare the type to `embedded_begin` and `embedded_end`.
16 | || node.parent && isEmptyEmbedded(node.parent);
17 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/binding.gyp:
--------------------------------------------------------------------------------
1 | {
2 | "targets": [
3 | {
4 | "target_name": "tree_sitter_twig_binding",
5 | "include_dirs": [
6 | " path.join(file.path, file.name);
6 |
7 | export default async function getTwigFiles(dir: string) {
8 | const dirFiles = await fs.readdir(dir, { withFileTypes: true });
9 |
10 | const twigFiles: string[] = [];
11 | for (const file of dirFiles) {
12 | if (file.isDirectory()) {
13 | const subdirTwigFiles = await getTwigFiles(getFullPath(file));
14 | twigFiles.push(...subdirTwigFiles);
15 | continue;
16 | }
17 |
18 | if (file.name.endsWith('.twig')) {
19 | twigFiles.push(getFullPath(file));
20 | }
21 | }
22 |
23 | return twigFiles;
24 | }
25 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/keywords.ts:
--------------------------------------------------------------------------------
1 | import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node';
2 | import { SyntaxNode } from 'web-tree-sitter';
3 | import { twigKeywords } from '../staticCompletionInfo';
4 | import { isInsideStatement } from '../utils/node/isInsideStatement';
5 |
6 | export const commonCompletionItem: Partial = {
7 | kind: CompletionItemKind.Keyword,
8 | commitCharacters: [' '],
9 | };
10 |
11 | const completions: CompletionItem[] = twigKeywords.map(item => ({
12 | ...commonCompletionItem,
13 | label: item.label,
14 | insertText: item.label,
15 | }));
16 |
17 | export function keywords(cursorNode: SyntaxNode): CompletionItem[] {
18 | if (isInsideStatement(cursorNode)) {
19 | return completions;
20 | }
21 |
22 | return [];
23 | }
24 |
--------------------------------------------------------------------------------
/packages/language-server/src/signature-helps/staticSignatureInformation.ts:
--------------------------------------------------------------------------------
1 | import { SignatureInformation } from 'vscode-languageserver';
2 | import { twigFunctions } from '../staticCompletionInfo';
3 |
4 | export const twigFunctionsSignatureInformation = new Map<
5 | string,
6 | SignatureInformation
7 | >(
8 | twigFunctions.map(item => {
9 | const label = item.label;
10 | const params = item.parameters?.map(item => item.label).join(', ');
11 | const signatureInformation: SignatureInformation = {
12 | label: `${item.label}(${params ?? ''})`,
13 | parameters: item.parameters,
14 | };
15 |
16 | if (item.return) {
17 | signatureInformation.label += `: ${item.return}`;
18 | }
19 |
20 | return [label, signatureInformation];
21 | }),
22 | );
23 |
--------------------------------------------------------------------------------
/packages/language-server/src/twigEnvironment/types.ts:
--------------------------------------------------------------------------------
1 | export type FunctionArgument = {
2 | identifier: string,
3 | defaultValue?: string,
4 | };
5 |
6 | export type TwigFunctionLike = {
7 | identifier: string,
8 | arguments: FunctionArgument[],
9 | };
10 |
11 | export type TwigVariable = {
12 | identifier: string,
13 | value: string,
14 | };
15 |
16 | export type TemplateNamespace = `@${string}` | '';
17 |
18 | export type TemplatePathMapping = {
19 | directory: string;
20 | namespace: TemplateNamespace;
21 | };
22 |
23 | export type TwigEnvironment = {
24 | Filters: TwigFunctionLike[],
25 | Functions: TwigFunctionLike[],
26 | Globals: TwigVariable[],
27 | LoaderPaths: TemplatePathMapping[],
28 | Tests: string[],
29 | };
30 |
31 | export type RouteNameToPathRecord = Record;
32 |
--------------------------------------------------------------------------------
/packages/language-server/src/semantic-tokens/tokens-provider.ts:
--------------------------------------------------------------------------------
1 | import { SemanticTokenTypes, SemanticTokensLegend } from 'vscode-languageserver';
2 |
3 | export const semanticTokensLegend: SemanticTokensLegend = {
4 | tokenTypes: [
5 | SemanticTokenTypes.parameter,
6 | SemanticTokenTypes.variable,
7 | SemanticTokenTypes.property,
8 | SemanticTokenTypes.function,
9 | SemanticTokenTypes.method,
10 | SemanticTokenTypes.keyword,
11 | SemanticTokenTypes.comment,
12 | SemanticTokenTypes.string,
13 | SemanticTokenTypes.number,
14 | SemanticTokenTypes.operator,
15 | SemanticTokenTypes.macro,
16 | SemanticTokenTypes.type,
17 | SemanticTokenTypes.namespace,
18 | SemanticTokenTypes.class,
19 | 'embedded_begin',
20 | 'embedded_end',
21 | 'null',
22 | 'boolean',
23 | ],
24 | tokenModifiers: [],
25 | };
26 |
--------------------------------------------------------------------------------
/packages/language-server/src/typing/TypeResolver.ts:
--------------------------------------------------------------------------------
1 | import { ReflectedType } from '../phpInterop/ReflectedType';
2 | import { ITypeResolver } from './ITypeResolver';
3 | import { IPhpExecutor } from '../phpInterop/IPhpExecutor';
4 |
5 | export class TypeResolver implements ITypeResolver {
6 | #typeCache = new Map();
7 |
8 | constructor(
9 | private readonly phpExecutor: IPhpExecutor,
10 | ) {
11 | }
12 |
13 | async reflectType(typeName: string): Promise {
14 | if (!typeName) return null;
15 |
16 | const cachedType = this.#typeCache.get(typeName);
17 | if (cachedType) return cachedType;
18 |
19 | const type = await this.phpExecutor.reflectType(typeName);
20 | this.#typeCache.set(typeName, type);
21 |
22 | return type;
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/packages/language-server/src/configuration/LanguageServerSettings.ts:
--------------------------------------------------------------------------------
1 | export type InlayHintSettings = {
2 | macro: boolean,
3 | macroArguments: boolean,
4 | block: boolean,
5 | };
6 |
7 | export const enum PhpFrameworkOption {
8 | Ignore = 'ignore',
9 | Twig = 'twig',
10 | Symfony = 'symfony',
11 | Craft = 'craft',
12 | }
13 |
14 | export type PhpFramework = PhpFrameworkOption.Twig | PhpFrameworkOption.Symfony | PhpFrameworkOption.Craft;
15 |
16 | type DiagnosticsSettings = {
17 | twigCsFixer: boolean,
18 | };
19 |
20 | export type LanguageServerSettings = {
21 | autoInsertSpaces: boolean,
22 | inlayHints: InlayHintSettings,
23 |
24 | framework?: PhpFrameworkOption,
25 | phpExecutable: string,
26 | symfonyConsolePath: string,
27 | vanillaTwigEnvironmentPath: string,
28 | diagnostics: DiagnosticsSettings,
29 | };
30 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/tree-sitter.json:
--------------------------------------------------------------------------------
1 | {
2 | "grammars": [
3 | {
4 | "name": "twig",
5 | "camelcase": "Twig",
6 | "scope": "twig",
7 | "path": ".",
8 | "file-types": [
9 | "twig",
10 | "html.twig"
11 | ],
12 | "highlights": [
13 | "queries/highlights.scm"
14 | ],
15 | "injections": "queries/injections.scm",
16 | "injection-regex": "twig"
17 | }
18 | ],
19 | "metadata": {
20 | "version": "0.4.0",
21 | "license": "Mozilla Public License 2.0",
22 | "description": "Twig grammar for tree-sitter",
23 | "links": {
24 | "repository": "https://github.com/tree-sitter/tree-sitter-twig"
25 | }
26 | },
27 | "bindings": {
28 | "c": false,
29 | "go": false,
30 | "node": true,
31 | "python": false,
32 | "rust": false,
33 | "swift": false
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/exec.ts:
--------------------------------------------------------------------------------
1 | import { spawn, SpawnOptionsWithoutStdio } from 'node:child_process';
2 |
3 | export type CommandResult = { stdout: string, stderr: string, code: number | null };
4 |
5 | export const exec = async (command: string, args: string[], options: SpawnOptionsWithoutStdio = {}): Promise => {
6 | return new Promise(resolve => {
7 | const child = spawn(command, args, options);
8 | let stdout = '';
9 | let stderr = '';
10 |
11 | child.stdout.on('data', (data) => stdout += data);
12 | child.stderr.on('data', (data) => stderr += data);
13 |
14 | child.on('close', (code) => resolve({ stdout, stderr, code }));
15 | });
16 | };
17 |
18 | export const isProcessError = (error: any): error is Error & CommandResult => {
19 | return error instanceof Error && 'stderr' in error && 'stdout' in error;
20 | };
21 |
--------------------------------------------------------------------------------
/packages/language-server/src/commands/IsInsideHtmlRegionCommandProvider.ts:
--------------------------------------------------------------------------------
1 | import { Connection } from 'vscode-languageserver';
2 | import { isInsideHtmlRegion } from '../utils/node';
3 | import { DocumentCache } from '../documents';
4 | import { IsInsideHtmlRegionRequest } from 'customRequests/IsInsideHtmlRegionRequest';
5 |
6 | export class IsInsideHtmlRegionCommandProvider {
7 | constructor(
8 | connection: Connection,
9 | private readonly documentCache: DocumentCache,
10 | ) {
11 | connection.onRequest(
12 | IsInsideHtmlRegionRequest.type,
13 | this.isInsideHtmlRegionCommand.bind(this),
14 | );
15 | }
16 |
17 | private async isInsideHtmlRegionCommand({ textDocument, position }: IsInsideHtmlRegionRequest.ParamsType) {
18 | const document = await this.documentCache.get(textDocument.uri);
19 | return isInsideHtmlRegion(document, position)
20 | };
21 | }
22 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/isPathInsideTemplateEmbedding.ts:
--------------------------------------------------------------------------------
1 | import { templateUsingFunctions, templateUsingStatements } from 'constants/template-usage';
2 | import { SyntaxNode } from 'web-tree-sitter';
3 | import { parseFunctionCall } from './parseFunctionCall';
4 |
5 | export const isPathInsideTemplateEmbedding = (node: SyntaxNode): boolean => {
6 | if (node.type !== 'string' || !node.parent) {
7 | return false;
8 | }
9 |
10 | const isInsideStatement = templateUsingStatements.includes(
11 | node.parent.type,
12 | );
13 |
14 | if (isInsideStatement) {
15 | return true;
16 | }
17 |
18 | const isInsideFunctionCall =
19 | node.parent?.type === 'arguments' &&
20 | templateUsingFunctions.some((func) =>
21 | parseFunctionCall(node.parent!.parent)?.name === func,
22 | );
23 |
24 | return isInsideFunctionCall;
25 | };
26 |
--------------------------------------------------------------------------------
/packages/language-server/src/hovers/for-loop.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 | import { forLoopProperties } from '../staticCompletionInfo';
3 | import { findParentByType } from '../utils/node';
4 | import { Hover } from 'vscode-languageserver';
5 |
6 | export function forLoop(cursorNode: SyntaxNode): Hover | undefined {
7 | if (!findParentByType(cursorNode, 'for')) {
8 | return;
9 | }
10 |
11 | if (
12 | cursorNode.type === 'property' &&
13 | cursorNode.previousSibling?.text === '.' &&
14 | cursorNode.previousSibling?.previousSibling?.type === 'variable' &&
15 | cursorNode.previousSibling?.previousSibling?.text === 'loop'
16 | ) {
17 | const property = forLoopProperties.find(
18 | item => item.label === cursorNode.text,
19 | );
20 |
21 | if (!property?.detail) return;
22 |
23 | return {
24 | contents: property.detail,
25 | };
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/files/resolveTemplate.ts:
--------------------------------------------------------------------------------
1 | import { isFile } from './fileStat';
2 |
3 | // HACK: Maybe we should move these to a configuration setting?
4 | const extensions = ['twig', 'html'];
5 | const CRAFT_INDEX_TEMPLATE_NAME = 'index';
6 |
7 | export function* generateResolveSequence(pathToTwig: string): Generator {
8 | yield pathToTwig;
9 |
10 | for (const extension of extensions) {
11 | yield `${pathToTwig}.${extension}`;
12 | }
13 |
14 | for (const extension of extensions) {
15 | yield `${pathToTwig}/${CRAFT_INDEX_TEMPLATE_NAME}.${extension}`;
16 | }
17 | }
18 |
19 | /**
20 | * Searches for a template file, and returns the first match if there is one.
21 | */
22 | export const resolveTemplate = async (
23 | pathToTwig: string,
24 | ): Promise => {
25 | for (const path of generateResolveSequence(pathToTwig)) {
26 | if (await isFile(path)) {
27 | return path;
28 | }
29 | }
30 |
31 | return null;
32 | };
33 |
--------------------------------------------------------------------------------
/packages/language-server/src/formatting/FormattingProvider.ts:
--------------------------------------------------------------------------------
1 | import { Connection, DocumentFormattingParams } from 'vscode-languageserver';
2 | import { TwigCodeStyleFixer } from 'phpInterop/TwigCodeStyleFixer';
3 | import { DiagnosticProvider } from 'diagnostics';
4 |
5 | export class FormattingProvider {
6 | #twigCodeStyleFixer: TwigCodeStyleFixer | null = null;
7 |
8 | constructor(
9 | connection: Connection,
10 | private readonly diagnosticProvider: DiagnosticProvider,
11 | ) {
12 | connection.onDocumentFormatting(this.onDocumentFormatting.bind(this));
13 | }
14 |
15 | refresh(twigCodeStyleFixer: TwigCodeStyleFixer | null) {
16 | this.#twigCodeStyleFixer = twigCodeStyleFixer;
17 | }
18 |
19 | async onDocumentFormatting(params: DocumentFormattingParams) {
20 | if (!this.#twigCodeStyleFixer) {
21 | return null;
22 | }
23 |
24 | await this.#twigCodeStyleFixer.fix(params.textDocument.uri);
25 | await this.diagnosticProvider.lint(params.textDocument.uri);
26 |
27 | return null;
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Publish to NPM
2 |
3 | on:
4 | workflow_dispatch: ~
5 | push:
6 | tags:
7 | - "v[0-9]+.[0-9]+.[0-9]+"
8 |
9 | env:
10 | NPM_TOKEN: ${{ secrets.NPM_PAT }}
11 | VSCE_PAT: ${{ secrets.VS_MARKETPLACE_TOKEN }}
12 |
13 | jobs:
14 | deploy:
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v4
18 |
19 | - uses: pnpm/action-setup@v4
20 | with:
21 | version: 9.6.0
22 | run_install: |
23 | - recursive: true
24 | args: [--frozen-lockfile, --strict-peer-dependencies]
25 | - args: [--global, "@vscode/vsce"]
26 |
27 | - uses: actions/setup-node@v4
28 | with:
29 | node-version: 20
30 | cache: 'pnpm'
31 |
32 | - run: pnpm build
33 |
34 | - run: vsce publish --no-dependencies
35 | working-directory: ./packages/vscode
36 |
37 | - name: pnpm publish
38 | working-directory: ./packages/language-server
39 | run: |
40 | npm config set "//registry.npmjs.org/:_authToken" "${NPM_TOKEN}"
41 | pnpm publish --no-git-checks
42 |
--------------------------------------------------------------------------------
/packages/language-server/src/references/ReferenceProvider.ts:
--------------------------------------------------------------------------------
1 | import { Connection, Range, ReferenceParams } from 'vscode-languageserver/node';
2 | import { DocumentCache } from '../documents/DocumentCache';
3 | import { hasReferences } from '../symbols/types';
4 |
5 | const rangeToLocation = (range: Range, uri: string) => ({ uri, range });
6 |
7 | export class ReferenceProvider {
8 | constructor(
9 | connection: Connection,
10 | private readonly documentCache: DocumentCache,
11 | ) {
12 | connection.onReferences(this.onReferences.bind(this));
13 | }
14 |
15 | async onReferences(params: ReferenceParams) {
16 | const document = await this.documentCache.get(params.textDocument.uri);
17 | if (!document) {
18 | return;
19 | }
20 |
21 | const variable = document.variableAt(params.position);
22 | if (!variable) {
23 | return;
24 | }
25 |
26 | if (!hasReferences(variable)) {
27 | return;
28 | }
29 |
30 | return [
31 | rangeToLocation(variable.nameRange, document.uri),
32 | ...variable.references.map(range => rangeToLocation(range, document.uri)),
33 | ];
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/language-server/src/hovers/HoverProvider.ts:
--------------------------------------------------------------------------------
1 | import { Connection, HoverParams } from 'vscode-languageserver';
2 | import { globalVariables } from './global-variables';
3 | import { localVariables } from './local-variables';
4 | import { forLoop } from './for-loop';
5 | import { functions } from './functions';
6 | import { filters } from './filters';
7 | import { DocumentCache } from '../documents';
8 |
9 | export class HoverProvider {
10 | constructor(
11 | private readonly connection: Connection,
12 | private readonly documentCache: DocumentCache,
13 | ) {
14 | this.connection.onHover(this.onHover.bind(this));
15 | }
16 |
17 | async onHover(params: HoverParams) {
18 | const document = await this.documentCache.get(params.textDocument.uri);
19 |
20 | if (!document) {
21 | return;
22 | }
23 |
24 | const cursorNode = document.deepestAt(params.position);
25 | if (!cursorNode) {
26 | return;
27 | }
28 |
29 | return globalVariables(cursorNode)
30 | || localVariables(document, cursorNode)
31 | || functions(cursorNode)
32 | || filters(cursorNode)
33 | || forLoop(cursorNode);
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/global-variables.ts:
--------------------------------------------------------------------------------
1 | import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node';
2 | import { SyntaxNode } from 'web-tree-sitter';
3 | import { twigGlobalVariables } from '../staticCompletionInfo';
4 | import { TwigVariable } from '../twigEnvironment/types';
5 | import { isInExpressionScope } from '../utils/node';
6 |
7 | const commonCompletionItem: Partial = {
8 | kind: CompletionItemKind.Variable,
9 | commitCharacters: ['|', '.'],
10 | detail: 'global variable',
11 | };
12 |
13 | const completions: CompletionItem[] = twigGlobalVariables.map((item) => ({
14 | ...commonCompletionItem,
15 | ...item,
16 | }));
17 |
18 | export function globalVariables(cursorNode: SyntaxNode, globals: TwigVariable[]): CompletionItem[] {
19 | if (cursorNode.type === 'variable' || isInExpressionScope(cursorNode)) {
20 | const completionsPhp = globals.map((variable): CompletionItem => ({
21 | ...commonCompletionItem,
22 | label: variable.identifier,
23 | }));
24 |
25 | return [
26 | ...completions,
27 | ...completionsPhp.filter(comp => !completions.find(item => item.label === comp.label)),
28 | ];
29 | }
30 |
31 | return [];
32 | }
33 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/filters.ts:
--------------------------------------------------------------------------------
1 | import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node';
2 | import { SyntaxNode } from 'web-tree-sitter';
3 | import { twigFilters } from '../staticCompletionInfo';
4 | import { TwigFunctionLike } from '../twigEnvironment/types';
5 |
6 | const commonCompletionItem: Partial = {
7 | kind: CompletionItemKind.Function,
8 | detail: 'filter',
9 | };
10 |
11 | const completions: CompletionItem[] = twigFilters.map((item) => ({
12 | ...commonCompletionItem,
13 | ...item,
14 | }));
15 |
16 | export function filters(cursorNode: SyntaxNode, filters: TwigFunctionLike[]): CompletionItem[] {
17 | if (
18 | cursorNode.text === '|' ||
19 | (cursorNode.type === 'function' &&
20 | cursorNode.parent!.type === 'filter_expression')
21 | ) {
22 | const completionsPhp = filters.map(
23 | (func): CompletionItem => ({
24 | ...commonCompletionItem,
25 | label: func.identifier,
26 | }),
27 | );
28 |
29 | return [
30 | ...completions,
31 | ...completionsPhp.filter((comp) => !completions.find((item) => item.label === comp.label)),
32 | ];
33 | }
34 |
35 | return [];
36 | }
37 |
--------------------------------------------------------------------------------
/packages/language-server/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twiggy-language-server",
3 | "version": "0.19.1",
4 | "author": "Mikhail Gunin ",
5 | "license": "Mozilla Public License 2.0",
6 | "main": "dist/server.js",
7 | "repository": {
8 | "type": "git",
9 | "url": "git+https://github.com/moetelo/twiggy.git",
10 | "directory": "packages/language-server"
11 | },
12 | "bin": {
13 | "twiggy-language-server": "bin/server.js"
14 | },
15 | "files": [
16 | "dist/",
17 | "bin/",
18 | "LICENSE",
19 | "README.md"
20 | ],
21 | "scripts": {
22 | "build": "esbuild ./src/index.ts --bundle --outfile=dist/index.js --format=cjs --platform=node",
23 | "test:watch": "glob -c \"node --import tsx --no-warnings --test --watch\" \"./__tests__/**/*.test.ts\"",
24 | "test": "glob -c \"node --import tsx --no-warnings --test\" \"./__tests__/**/*.test.ts\""
25 | },
26 | "devDependencies": {
27 | "@types/node": "^20.12.7",
28 | "esbuild": "^0.20.2",
29 | "glob": "^10.3.15",
30 | "tsx": "^4.10.5",
31 | "typescript": "^5.4.5"
32 | },
33 | "dependencies": {
34 | "vscode-languageserver": "^9.0.1",
35 | "vscode-languageserver-textdocument": "^1.0.11",
36 | "vscode-uri": "^3.0.8",
37 | "web-tree-sitter": "^0.22.5"
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/routes.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CompletionItem,
3 | CompletionItemKind,
4 | } from 'vscode-languageserver/node';
5 | import { SyntaxNode } from 'web-tree-sitter';
6 |
7 | export const commonCompletionItem: Partial = {
8 | kind: CompletionItemKind.EnumMember,
9 | commitCharacters: [`'"`],
10 | detail: 'symfony path',
11 | };
12 |
13 | const isInStringInsideOfPathCall = (cursorNode: SyntaxNode): boolean => {
14 | if (!(cursorNode.type === 'string'
15 | && cursorNode.parent?.type === 'arguments'
16 | && !!cursorNode.parent.firstNamedChild?.equals(cursorNode)
17 | )) {
18 | return false;
19 | }
20 |
21 | const functionName = cursorNode.parent.parent!.childForFieldName('name')?.text;
22 | if (!functionName) return false;
23 |
24 | return ['path', 'is_route'].includes(functionName);
25 | }
26 |
27 | export function symfonyRouteNames(cursorNode: SyntaxNode, routeNames: string[]): CompletionItem[] {
28 | if (isInStringInsideOfPathCall(cursorNode)) {
29 | const completions: CompletionItem[] = routeNames.map((routeName) => ({
30 | ...commonCompletionItem,
31 | label: routeName,
32 | }));
33 |
34 | return completions;
35 | }
36 |
37 | return [];
38 | }
39 |
--------------------------------------------------------------------------------
/packages/language-server/src/utils/node/parseFunctionCall.ts:
--------------------------------------------------------------------------------
1 | import { Range } from 'vscode-languageserver';
2 | import { SyntaxNode } from 'web-tree-sitter';
3 | import { getNodeRange } from './getNodeRange';
4 |
5 | type FunctionCallArgument = {
6 | name: string,
7 | range: Range,
8 | };
9 |
10 | type FunctionCall = {
11 | object?: string,
12 | name: string,
13 | args: FunctionCallArgument[],
14 | };
15 |
16 | const toFunctionCallArgument = (node: SyntaxNode): FunctionCallArgument => ({
17 | name: node.text,
18 | range: getNodeRange(node)
19 | });
20 |
21 | export const parseFunctionCall = (
22 | node: SyntaxNode | null,
23 | ): FunctionCall | undefined => {
24 | const isCall = !!node && node.type === 'call_expression';
25 | if (!isCall) return;
26 |
27 | const nameNode = node.childForFieldName('name')!;
28 |
29 | const objectNode = nameNode.childForFieldName('object');
30 | const functionNameNode = nameNode.type === 'function'
31 | ? nameNode
32 | : nameNode.childForFieldName('property');
33 |
34 | const argNodes = node.childForFieldName('arguments')?.namedChildren;
35 | const args = argNodes?.map(toFunctionCallArgument) || [];
36 |
37 | return {
38 | object: objectNode?.text,
39 | name: functionNameNode!.text,
40 | args,
41 | };
42 | };
43 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "0.2.0",
3 | "configurations": [
4 | {
5 | "name": "Extension",
6 | "type": "extensionHost",
7 | "request": "launch",
8 | "preLaunchTask": "${defaultBuildTask}",
9 | "args": [
10 | "--extensionDevelopmentPath=${workspaceFolder}/packages/vscode",
11 | "--disable-extensions",
12 | ],
13 | "outFiles": [
14 | "${workspaceFolder}/packages/vscode/dist/*",
15 | ],
16 | },
17 | {
18 | "name": "Server",
19 | "type": "node",
20 | "request": "attach",
21 | "port": 6009,
22 | "restart": true,
23 | "sourceMaps": true,
24 | "smartStep": true,
25 | "outFiles": [
26 | "${workspaceRoot}/packages/vscode/dist/*",
27 | ],
28 | },
29 | {
30 | "type": "node",
31 | "request": "attach",
32 | "name": "Attach to Server",
33 | "address": "127.0.0.1",
34 | "sourceMaps": true,
35 | "smartStep": true,
36 | "port": 9229,
37 | },
38 | {
39 | "name": "Listen for XDebug",
40 | "type": "php",
41 | "request": "launch",
42 | "port": 9003,
43 | },
44 | ],
45 | "compounds": [
46 | {
47 | "name": "Extension + Server",
48 | "configurations": [
49 | "Server",
50 | "Extension",
51 | ],
52 | "stopAll": true
53 | }
54 | ]
55 | }
56 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/src/scanner.c:
--------------------------------------------------------------------------------
1 | #include
2 | #include
3 | #include
4 |
5 | enum TokenType {
6 | CONTENT,
7 | };
8 |
9 | void *tree_sitter_twig_external_scanner_create() { return NULL; }
10 | void tree_sitter_twig_external_scanner_destroy(void *p) {}
11 | void tree_sitter_twig_external_scanner_reset(void *p) {}
12 | unsigned tree_sitter_twig_external_scanner_serialize(void *p, char *buffer) { return 0; }
13 | void tree_sitter_twig_external_scanner_deserialize(void *p, const char *b, unsigned n) {}
14 |
15 | static void advance(TSLexer *lexer) { lexer->advance(lexer, false); }
16 |
17 | bool tree_sitter_twig_external_scanner_scan(void *payload, TSLexer *lexer, const bool *valid_symbols) {
18 | // Eat whitespace
19 | while (iswspace(lexer->lookahead)) {
20 | lexer->advance(lexer, true);
21 | }
22 |
23 | // CONTENT
24 | bool has_content = false;
25 |
26 | while (lexer->lookahead) {
27 | if(lexer->lookahead == '{') {
28 | advance(lexer);
29 |
30 | if(lexer->lookahead == '{' ||
31 | lexer->lookahead == '%' ||
32 | lexer->lookahead == '#') {
33 | break;
34 | }
35 | } else {
36 | advance(lexer);
37 | }
38 |
39 | lexer->mark_end(lexer);
40 | has_content = true;
41 | }
42 |
43 | if (has_content) {
44 | lexer->result_symbol = CONTENT;
45 | return true;
46 | }
47 |
48 | return false;
49 | }
50 |
--------------------------------------------------------------------------------
/packages/language-server/src/signature-helps/SignatureIndex.ts:
--------------------------------------------------------------------------------
1 | import { SignatureInformation } from 'vscode-languageserver';
2 | import { TwigEnvironment, TwigFunctionLike } from '../twigEnvironment';
3 |
4 | export class SignatureIndex {
5 | #index: Map;
6 |
7 | constructor(twigEnvironment: TwigEnvironment | null) {
8 | this.#index = new Map();
9 |
10 | if (!twigEnvironment) return;
11 |
12 | for (const fun of twigEnvironment.Functions) {
13 | this.#index.set(fun.identifier, this.#mapToSignatureInformation(fun));
14 | }
15 |
16 | for (const fun of twigEnvironment.Filters) {
17 | this.#index.set(fun.identifier, this.#mapToSignatureInformation(fun));
18 | }
19 | }
20 |
21 | get(func: string): SignatureInformation | undefined {
22 | return this.#index.get(func);
23 | }
24 |
25 | #mapToSignatureInformation(item: TwigFunctionLike): SignatureInformation {
26 | const paramsStr = item.arguments
27 | .map(({ identifier, defaultValue }) => defaultValue ? `${identifier} = ${defaultValue}` : identifier)
28 | .join(', ');
29 |
30 | return {
31 | label: `${item.identifier}(${paramsStr})`,
32 | parameters: item.arguments.map(arg => ({
33 | label: arg.identifier,
34 | })),
35 | };
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/packages/vscode/languages/twig.configuration.json:
--------------------------------------------------------------------------------
1 | {
2 | "comments": {
3 | "blockComment": [ "{#", "#}" ]
4 | },
5 | "brackets": [
6 | ["{", "}"],
7 | ["[", "]"],
8 | ["(", ")"],
9 | ["{{", "}}"],
10 | ["{%", "%}"]
11 | ],
12 | "autoClosingPairs": [
13 | ["{{", "}}"],
14 | ["{{ ", " "],
15 | ["{%", "%}"],
16 | ["{% ", " "],
17 | ["[", "]"],
18 | ["{", "}"],
19 | ["(", ")"],
20 | ["'", "'"],
21 | ["\"", "\""]
22 | ],
23 | "colorizedBracketPairs": [
24 | ["(", ")"],
25 | ["[", "]"],
26 | ["{", "}"]
27 | ],
28 | "wordPattern": "((?<=\\{%\\s+\\w+\\s+)([\"'].+?[\"'])|(?<=\\{\\{\\s*(include|source)\\()([\"'].+?[\"'])|([A-Za-z:_-]+)|([^'\"();{}%$<>/.,=|\\- ]+))",
29 | "indentationRules": {
30 | "increaseIndentPattern": "(<(?!\\?|(?:area|base|br|col|frame|hr|html|img|input|keygen|link|menuitem|meta|param|source|track|wbr)\\b|[^>]*\\/>)([-_\\.A-Za-z0-9]+)(?=\\s|>)\\b[^>]*>(?!.*<\\/\\1>)|)|\\{[^}\"']*|^\\s*\\{%\\s+?(block|if|macro|for|else).+?%\\})$",
31 | "decreaseIndentPattern": "^(\\s*(<\\/[-_\\.A-Za-z0-9]+\\b[^>]*>|-->|\\})|\\{%\\s+?(endblock|endif|endmacro|endfor).+?%\\})"
32 | },
33 | "surroundingPairs": [
34 | ["{", "}"],
35 | ["[", "]"],
36 | ["(", ")"],
37 | ["\"", "\""],
38 | ["'", "'"]
39 | ]
40 | }
41 |
--------------------------------------------------------------------------------
/packages/language-server/src/hovers/local-variables.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 | import { Hover } from 'vscode-languageserver';
3 | import { Document } from '../documents';
4 | import { pointToPosition } from '../utils/position';
5 | import { TwigImport, TwigVariableDeclaration } from '../symbols/types';
6 |
7 | export function localVariables(document: Document, cursorNode: SyntaxNode): Hover | undefined {
8 | if (cursorNode.type !== 'variable') return undefined;
9 |
10 | const locals = document.getLocalsAt(
11 | pointToPosition(cursorNode.startPosition),
12 | );
13 |
14 | const result: TwigImport | TwigVariableDeclaration | undefined = locals.find(({ name }) => name === cursorNode.text);
15 |
16 | if (!result) return undefined;
17 |
18 | if (TwigImport.is(result)) {
19 | return {
20 | contents: {
21 | kind: 'markdown',
22 | value: `import '${result.path}'`,
23 | },
24 | };
25 | }
26 |
27 | if (result.type) {
28 | return {
29 | contents: {
30 | kind: 'markdown',
31 | value: result.type,
32 | },
33 | };
34 | }
35 |
36 | if (result.value) {
37 | return {
38 | contents: {
39 | kind: 'markdown',
40 | value: `**${result.name}** = ${result.value}`,
41 | },
42 | };
43 | }
44 |
45 | return undefined;
46 | }
47 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/functions.ts:
--------------------------------------------------------------------------------
1 | import {
2 | CompletionItem,
3 | CompletionItemKind,
4 | InsertTextFormat,
5 | } from 'vscode-languageserver/node';
6 | import { SyntaxNode } from 'web-tree-sitter';
7 | import { twigFunctions } from '../staticCompletionInfo';
8 | import { TwigFunctionLike } from '../twigEnvironment/types';
9 | import { isInExpressionScope } from '../utils/node';
10 | import { triggerParameterHints } from '../signature-helps/triggerParameterHintsCommand';
11 |
12 | export const commonCompletionItem: Partial = {
13 | kind: CompletionItemKind.Function,
14 | insertTextFormat: InsertTextFormat.Snippet,
15 | command: triggerParameterHints,
16 | detail: 'function',
17 | };
18 |
19 | const completions: CompletionItem[] = twigFunctions.map((item) => ({
20 | ...item,
21 | ...commonCompletionItem,
22 | insertText: `${item.label}($1)$0`,
23 | }));
24 |
25 | export function functions(cursorNode: SyntaxNode, functions: TwigFunctionLike[]): CompletionItem[] {
26 | if (['variable', 'function'].includes(cursorNode.type) || isInExpressionScope(cursorNode)) {
27 | const completionsPhp = functions.map((func): CompletionItem => ({
28 | ...commonCompletionItem,
29 | label: func.identifier,
30 | insertText: `${func.identifier}($1)$0`,
31 | }));
32 |
33 | return [
34 | ...completions,
35 | ...completionsPhp.filter(comp => !completions.find(item => item.label === comp.label)),
36 | ];
37 | }
38 |
39 | return [];
40 | }
41 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/local-variables.ts:
--------------------------------------------------------------------------------
1 | import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node';
2 | import { SyntaxNode } from 'web-tree-sitter';
3 | import { Document } from '../documents';
4 | import { FunctionArgument, hasReflectedType, TwigVariableDeclaration } from '../symbols/types';
5 | import { isInExpressionScope } from '../utils/node';
6 | import { pointToPosition } from '../utils/position';
7 | import { positionsEqual } from '../utils/position/comparePositions';
8 |
9 | const toCompletionItem = (variable: TwigVariableDeclaration | FunctionArgument): CompletionItem => ({
10 | label: variable.name,
11 | kind: CompletionItemKind.Field,
12 | detail: hasReflectedType(variable) ? variable.type : variable.value,
13 | });
14 |
15 | export function localVariables(document: Document, cursorNode: SyntaxNode): CompletionItem[] {
16 | if (cursorNode.type !== 'variable' && !isInExpressionScope(cursorNode)) {
17 | return [];
18 | }
19 |
20 | const locals = document.getLocalsAt(
21 | pointToPosition(cursorNode.startPosition),
22 | );
23 |
24 | // excludes the current variable from the completion list
25 | // so it doesn't show up when the cursor is on the variable name
26 | const localsWithoutCurrentVariable = locals.filter(local =>
27 | !(positionsEqual(local.nameRange.start, pointToPosition(cursorNode.startPosition))
28 | && positionsEqual(local.nameRange.end, pointToPosition(cursorNode.endPosition)))
29 | );
30 |
31 | return localsWithoutCurrentVariable.map(toCompletionItem);
32 | }
33 |
--------------------------------------------------------------------------------
/packages/language-server/phpUtils/printCraftTwigEnvironment.php:
--------------------------------------------------------------------------------
1 | getView();
34 | $twig = $view->getTwig();
35 |
36 | // Add Craft's template path to Twig's loader paths
37 | $templateRoots = ['' => [$templatesPath ?? 'templates']];
38 | $templateRoots = array_merge($templateRoots, $view->getSiteTemplateRoots());
39 |
40 | $twigMetadata = \Twiggy\Metadata\getTwigMetadata($twig, 'craft');
41 | $twigMetadata['loader_paths'] = $templateRoots;
42 |
43 | echo json_encode($twigMetadata, JSON_PRETTY_PRINT) . PHP_EOL;
44 |
--------------------------------------------------------------------------------
/packages/language-server/phpUtils/completeClassPsr4.php:
--------------------------------------------------------------------------------
1 | getPrefixesPsr4();
18 | $prefixesPsr4 = array_keys($prefixesPsr4ToPaths);
19 |
20 | if (!$NAMESPACE_PSR4) {
21 | echo json_encode($prefixesPsr4, JSON_PRETTY_PRINT) . PHP_EOL;
22 | exit(0);
23 | }
24 |
25 | /** @var array $classesInNamespace */
26 | $classesInNamespace = [];
27 | $namespaceFirstPart = explode('\\', $NAMESPACE_PSR4)[0];
28 |
29 | foreach ($prefixesPsr4 as $prefix) {
30 | if (!str_starts_with($prefix, $namespaceFirstPart)) {
31 | continue;
32 | }
33 |
34 | $dir = $prefixesPsr4ToPaths[$prefix][0];
35 | $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir));
36 |
37 | foreach ($iterator as $file) {
38 | if ($file->isDir()) continue;
39 | if (pathinfo($file->getFilename(), PATHINFO_EXTENSION) === 'php') {
40 | include_once $file->getPathname();
41 | }
42 | }
43 |
44 | foreach (get_declared_classes() as $class) {
45 | if (!str_starts_with($class, $NAMESPACE_PSR4)) {
46 | continue;
47 | }
48 |
49 | $classesInNamespace[$class] = true;
50 | }
51 | }
52 |
53 | echo json_encode(array_keys($classesInNamespace), JSON_PRETTY_PRINT) . PHP_EOL;
54 |
--------------------------------------------------------------------------------
/packages/language-server/src/twigEnvironment/CraftTwigEnvironment.ts:
--------------------------------------------------------------------------------
1 | import { PhpExecutor } from '../phpInterop/PhpExecutor';
2 | import { EmptyEnvironment, IFrameworkTwigEnvironment } from './IFrameworkTwigEnvironment';
3 | import { PhpUtilPath } from './PhpUtilPath';
4 | import { TwigEnvironmentArgs } from './TwigEnvironmentArgs';
5 | import { SymfonyTwigDebugJsonOutput, parseDebugTwigOutput } from './symfony/parseDebugTwigOutput';
6 | import { TemplatePathMapping, TwigEnvironment } from './types';
7 |
8 | export class CraftTwigEnvironment implements IFrameworkTwigEnvironment {
9 | #environment: TwigEnvironment | null = null;
10 | readonly routes = Object.freeze({});
11 |
12 | constructor(private readonly _phpExecutor: PhpExecutor) {
13 | }
14 |
15 | get environment() {
16 | return this.#environment;
17 | }
18 |
19 | async refresh({ workspaceDirectory }: TwigEnvironmentArgs): Promise {
20 | this.#environment = await this.#loadEnvironment(workspaceDirectory);
21 | }
22 |
23 | get templateMappings(): TemplatePathMapping[] {
24 | return this.#environment?.LoaderPaths?.length
25 | ? this.#environment.LoaderPaths
26 | : EmptyEnvironment.templateMappings;
27 | }
28 |
29 | async #loadEnvironment(workspaceDirectory: string): Promise {
30 | const result = await this._phpExecutor.callJson(
31 | PhpUtilPath.getCraftTwig, [
32 | workspaceDirectory,
33 | ]
34 | );
35 |
36 | if (!result) {
37 | return null;
38 | }
39 |
40 | return parseDebugTwigOutput(result);
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/corpus/tests.txt:
--------------------------------------------------------------------------------
1 | ==================
2 | divisible by
3 | ==================
4 | {% if loop.index is divisible by(3) %}
5 | ...
6 | {% endif %}
7 | ---
8 | (template
9 | (if
10 | (binary_expression
11 | (member_expression
12 | (variable)
13 | (property))
14 | (parenthesized_expression
15 | (number)))
16 | (source_elements
17 | (content))))
18 |
19 | ==================
20 | same as parenthesized
21 | ==================
22 | {% if foo.attribute is same as(false) %}
23 | ...
24 | {% endif %}
25 | ---
26 | (template
27 | (if
28 | (binary_expression
29 | (member_expression
30 | (variable)
31 | (property))
32 | (parenthesized_expression
33 | (boolean)))
34 | (source_elements
35 | (content))))
36 |
37 | ==================
38 | same as
39 | ==================
40 | {% if foo is same as false %}
41 | ...
42 | {% endif %}
43 | ---
44 | (template
45 | (if
46 | (binary_expression
47 | (variable)
48 | (boolean))
49 | (source_elements
50 | (content))))
51 |
52 | ==================
53 | issue #18
54 | ==================
55 | {% if app.request.query.get('email') is same as email.uuid %}open{% endif %}
56 | ---
57 | (template
58 | (if
59 | (binary_expression
60 | (call_expression
61 | (member_expression
62 | (member_expression
63 | (member_expression
64 | (variable)
65 | (property))
66 | (property))
67 | (property))
68 | (arguments
69 | (string)))
70 | (member_expression
71 | (variable)
72 | (property)))
73 | (source_elements
74 | (content))))
75 |
--------------------------------------------------------------------------------
/packages/language-server/src/symbols/SymbolProvider.ts:
--------------------------------------------------------------------------------
1 | import { Connection, DocumentSymbol, DocumentSymbolParams, SymbolKind } from 'vscode-languageserver';
2 | import { LocalSymbolInformation } from './types';
3 | import { DocumentCache } from '../documents';
4 |
5 | const mapLocalsToSymbols = (locals: LocalSymbolInformation): DocumentSymbol[] => {
6 | return [
7 | ...locals.variable.map((item): DocumentSymbol => ({
8 | name: item.name,
9 | kind: SymbolKind.Variable,
10 | range: item.range,
11 | selectionRange: item.nameRange,
12 | })),
13 | ...locals.macro.map((item): DocumentSymbol => ({
14 | name: 'macro ' + item.name,
15 | kind: SymbolKind.Function,
16 | range: item.range,
17 | selectionRange: item.nameRange,
18 | children: mapLocalsToSymbols(item.symbols),
19 | })),
20 | ...locals.block.map((item): DocumentSymbol => ({
21 | name: 'block ' + item.name,
22 | kind: SymbolKind.Property,
23 | range: item.range,
24 | selectionRange: item.nameRange,
25 | children: mapLocalsToSymbols(item.symbols),
26 | })),
27 | ];
28 | };
29 |
30 | export class SymbolProvider {
31 | constructor(
32 | private readonly connection: Connection,
33 | private readonly documentCache: DocumentCache,
34 | ) {
35 | this.connection.onDocumentSymbol(this.onDocumentSymbol.bind(this));
36 | }
37 |
38 | async onDocumentSymbol(params: DocumentSymbolParams): Promise {
39 | const document = await this.documentCache.get(params.textDocument.uri);
40 |
41 | return mapLocalsToSymbols(document.locals);
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/packages/language-server/src/twigEnvironment/symfony/parseDebugTwigOutput.ts:
--------------------------------------------------------------------------------
1 | import {
2 | TemplateNamespace,
3 | TemplatePathMapping,
4 | TwigEnvironment,
5 | TwigFunctionLike,
6 | TwigVariable,
7 | } from '../types';
8 |
9 | export interface SymfonyTwigDebugJsonOutput {
10 | functions: Record;
11 | filters: Record;
12 | globals: Record;
13 | loader_paths: Record;
14 | tests: string[];
15 | }
16 |
17 | const toFunctionLike = ([identifier, args]: [string, string[] | null]): TwigFunctionLike => ({
18 | identifier,
19 | arguments: (args || []).map((arg) => {
20 | const [identifier, defaultValue] = arg.trim().split('=');
21 | return {
22 | identifier,
23 | defaultValue,
24 | };
25 | }),
26 | });
27 |
28 | const toTwigVariable = ([identifier, value]: [string, any]): TwigVariable => ({
29 | identifier,
30 | value,
31 | });
32 |
33 | const toTemplateMappings = ([namespaceRaw, directories]: [string, string[]]): TemplatePathMapping[] => {
34 | const namespace: TemplateNamespace = namespaceRaw === '(None)'
35 | ? ''
36 | : namespaceRaw as TemplateNamespace;
37 |
38 | return directories.map((directory) => ({
39 | namespace,
40 | directory,
41 | }));
42 | };
43 |
44 | export const parseDebugTwigOutput = (output: SymfonyTwigDebugJsonOutput): TwigEnvironment => ({
45 | Filters: Object.entries(output.filters).map(toFunctionLike),
46 | Functions: Object.entries(output.functions).map(toFunctionLike),
47 | Globals: Object.entries(output.globals).map(toTwigVariable),
48 | LoaderPaths: Object.entries(output.loader_paths).flatMap(toTemplateMappings),
49 | Tests: output.tests,
50 | });
51 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/snippets.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 | import { commonCompletionItem as commonFunctionCompletionItem } from './functions';
3 | import { SnippetLike, miscSnippets, twigFunctions, twigKeywords } from '../staticCompletionInfo';
4 | import { CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver';
5 | import { commonCompletionItem as commonKeywordCompletionItem } from './keywords';
6 | import { triggerCompletion } from './triggerCompletionCommand';
7 |
8 | const commonSnippetItem: Partial = {
9 | kind: CompletionItemKind.Snippet,
10 | insertTextFormat: InsertTextFormat.Snippet,
11 | detail: 'snippet',
12 | };
13 |
14 | const snippetLikeToCompletionItem = (snippetLike: SnippetLike): CompletionItem => ({
15 | ...commonKeywordCompletionItem,
16 | ...commonSnippetItem,
17 | label: snippetLike.label,
18 | command: triggerCompletion,
19 | insertText: snippetLike.snippet!.join('\n'),
20 | });
21 |
22 | const functionSnippets: CompletionItem[] = twigFunctions
23 | .filter(x => x.createSnippet)
24 | .map((item) => ({
25 | ...item,
26 | ...commonFunctionCompletionItem,
27 | ...commonSnippetItem,
28 | insertText: `{{ ${item.label}($1) }}$0`,
29 | }));
30 |
31 | const keywordSnippets: CompletionItem[] = twigKeywords
32 | .filter(item => item.snippet)
33 | .map(snippetLikeToCompletionItem);
34 |
35 | const miscSnippetCompletions = miscSnippets.map(snippetLikeToCompletionItem);
36 |
37 | export function snippets(cursorNode: SyntaxNode) {
38 | if (cursorNode.type !== 'content') {
39 | return [];
40 | }
41 |
42 | return [
43 | ...functionSnippets,
44 | ...keywordSnippets,
45 | ...miscSnippetCompletions,
46 | ];
47 | }
48 |
--------------------------------------------------------------------------------
/packages/language-server/src/twigEnvironment/VanillaTwigEnvironment.ts:
--------------------------------------------------------------------------------
1 | import { PhpExecutor } from '../phpInterop/PhpExecutor';
2 | import { EmptyEnvironment, IFrameworkTwigEnvironment } from './IFrameworkTwigEnvironment';
3 | import { PhpUtilPath } from './PhpUtilPath';
4 | import { TwigEnvironmentArgs } from './TwigEnvironmentArgs';
5 | import { SymfonyTwigDebugJsonOutput, parseDebugTwigOutput } from './symfony/parseDebugTwigOutput';
6 | import { RouteNameToPathRecord, TemplatePathMapping, TwigEnvironment } from './types';
7 |
8 | export class VanillaTwigEnvironment implements IFrameworkTwigEnvironment {
9 | #environment: TwigEnvironment | null = null;
10 | #routes: RouteNameToPathRecord = {};
11 |
12 | constructor(private readonly _phpExecutor: PhpExecutor) {
13 | }
14 |
15 | get environment() {
16 | return this.#environment;
17 | }
18 |
19 | get routes() {
20 | return this.#routes;
21 | }
22 |
23 | get templateMappings(): TemplatePathMapping[] {
24 | return this.#environment?.LoaderPaths?.length
25 | ? this.#environment.LoaderPaths
26 | : EmptyEnvironment.templateMappings;
27 | }
28 |
29 | async refresh({ vanillaTwigEnvironmentPath }: TwigEnvironmentArgs): Promise {
30 | this.#environment = await this.#loadEnvironment(vanillaTwigEnvironmentPath);
31 | }
32 |
33 | async #loadEnvironment(vanillaTwigEnvironmentPath: string): Promise {
34 | const result = await this._phpExecutor.callJson(
35 | PhpUtilPath.printTwigEnvironment, [
36 | vanillaTwigEnvironmentPath,
37 | ]
38 | );
39 |
40 | if (!result) {
41 | return null;
42 | }
43 |
44 | return parseDebugTwigOutput(result);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/packages/language-server/src/symbols/types.ts:
--------------------------------------------------------------------------------
1 | import { Range } from 'vscode-languageserver/node';
2 | import { ReflectedType } from '../phpInterop/ReflectedType';
3 |
4 | export interface LocalSymbol {
5 | name: string;
6 | nameRange: Range;
7 |
8 | range: Range;
9 | }
10 |
11 | type IWithReferences = { references: Range[] };
12 | type IWithReflectedType = {
13 | type?: string,
14 | reflectedType: ReflectedType | null,
15 | };
16 |
17 | export interface TwigVariableDeclaration extends LocalSymbol, IWithReferences, IWithReflectedType {
18 | value?: string;
19 | }
20 |
21 | export function hasReferences(node: T): node is T & IWithReferences {
22 | return 'references' in node;
23 | }
24 |
25 | export function hasReflectedType(node: T): node is T & IWithReflectedType {
26 | return 'reflectedType' in node && node.reflectedType !== null;
27 | }
28 |
29 | export interface FunctionArgument extends LocalSymbol {
30 | value?: string;
31 | }
32 |
33 | export interface TwigMacro extends LocalSymbol {
34 | args: FunctionArgument[];
35 | symbols: LocalSymbolInformation;
36 | }
37 |
38 | export interface TwigBlock extends LocalSymbol {
39 | symbols: LocalSymbolInformation;
40 | }
41 |
42 | export interface TwigImport extends LocalSymbol, IWithReferences {
43 | path?: string;
44 | }
45 |
46 | export namespace TwigImport {
47 | export const is = (node: LocalSymbol): node is TwigImport => 'path' in node;
48 | }
49 |
50 | export type LocalSymbolInformation = {
51 | variableDefinition: Map;
52 |
53 | extends: string | undefined;
54 | imports: TwigImport[];
55 | variable: TwigVariableDeclaration[];
56 | macro: TwigMacro[];
57 | block: TwigBlock[];
58 | };
59 |
--------------------------------------------------------------------------------
/packages/language-server/src/semantic-tokens/TokenTypeResolver.ts:
--------------------------------------------------------------------------------
1 | import { SemanticTokenTypes, SemanticTokensLegend } from 'vscode-languageserver';
2 | import { TreeCursor } from 'web-tree-sitter';
3 |
4 | const aliasedNodes = new Map([
5 | ['comment_begin', SemanticTokenTypes.comment],
6 | ['comment_end', SemanticTokenTypes.comment],
7 | ['php_identifier', SemanticTokenTypes.class],
8 | ['primitive_type', SemanticTokenTypes.type],
9 | ...'()[]{}:.,='.split('').map((operator) => [operator, SemanticTokenTypes.operator] as const),
10 | ]);
11 |
12 | export class TokenTypeResolver {
13 | readonly #tokenTypes: Map;
14 |
15 | constructor(semanticTokensLegend: SemanticTokensLegend) {
16 | this.#tokenTypes = new Map(
17 | semanticTokensLegend.tokenTypes.map((tokenType, index) => [tokenType, index]),
18 | );
19 | }
20 |
21 | get methodTokenType() {
22 | return this.#tokenTypes.get(SemanticTokenTypes.method)!;
23 | }
24 |
25 | resolve(node: TreeCursor) {
26 | if (node.nodeType === 'macro') {
27 | return undefined;
28 | }
29 |
30 | // Skip adding semantic tokens for comment_begin/comment_end nodes
31 | // inside of comments.
32 | if (node.currentNode.parent?.type === 'comment') {
33 | return undefined;
34 | }
35 |
36 | if (
37 | node.nodeType === 'property' &&
38 | node.currentNode.parent!.nextSibling?.type === 'arguments'
39 | ) {
40 | return this.methodTokenType;
41 | }
42 |
43 | const aliasedNodeTokenType = aliasedNodes.get(node.nodeType);
44 | if (aliasedNodeTokenType) {
45 | return this.#tokenTypes.get(aliasedNodeTokenType);
46 | }
47 |
48 | return this.#tokenTypes.get(node.nodeType);
49 | };
50 | }
51 |
--------------------------------------------------------------------------------
/packages/language-server/src/autoInsertions/BracketSpacesInsertionProvider.ts:
--------------------------------------------------------------------------------
1 | import { Connection, TextDocuments } from 'vscode-languageserver';
2 | import { AutoInsertRequest } from '../customRequests/AutoInsertRequest';
3 | import { TextDocument } from 'vscode-languageserver-textdocument';
4 |
5 | export class BracketSpacesInsertionProvider {
6 | isEnabled = true;
7 |
8 | readonly changeTriggers = new Map([
9 | ['%%', '{%%}'],
10 | ['{}', '{{}}'],
11 | ]);
12 |
13 | constructor(
14 | connection: Connection,
15 | private readonly documents: TextDocuments,
16 | ) {
17 | connection.onRequest(AutoInsertRequest.type, this.onAutoInsert.bind(this));
18 | }
19 |
20 | async onAutoInsert({ textDocument, options }: AutoInsertRequest.ParamsType): Promise {
21 | if (!this.isEnabled) return;
22 |
23 | const document = this.documents.get(textDocument.uri);
24 |
25 | if (!document) return;
26 |
27 | const { lastChange } = options;
28 |
29 | const fullTrigger = this.changeTriggers.get(lastChange.text);
30 |
31 | if (!fullTrigger) return;
32 |
33 | const textSurround = document.getText({
34 | start: { line: lastChange.range.start.line, character: lastChange.range.start.character - '{'.length },
35 | end: { line: lastChange.range.start.line, character: lastChange.range.start.character + '{}}'.length }
36 | });
37 |
38 | if (textSurround !== fullTrigger) return;
39 |
40 | return {
41 | newText: ` $0 `,
42 | range: {
43 | start: { line: lastChange.range.start.line, character: lastChange.range.start.character + 1 },
44 | end: { line: lastChange.range.start.line, character: lastChange.range.start.character + 1 }
45 | },
46 | };
47 | }
48 | }
49 |
--------------------------------------------------------------------------------
/packages/language-server/__tests__/diagnostics.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, test, before } from 'node:test'
2 | import * as assert from 'node:assert/strict'
3 | import { createDocumentCache, createLengthRange, initializeTestParser } from './utils';
4 | import Parser from 'web-tree-sitter';
5 | import { DiagnosticProvider } from '../src/diagnostics';
6 | import { Document } from 'documents';
7 |
8 |
9 | describe('diagnostics', () => {
10 | let parser!: Parser;
11 |
12 | const documentCache = createDocumentCache();
13 |
14 | let diagnosticProvider = new DiagnosticProvider(null as any, documentCache);
15 |
16 | before(async () => {
17 | parser = await initializeTestParser();
18 | });
19 |
20 | const testDiagnostic = async (code: string, start = 0, length = code.length) => {
21 | const document = new Document('test://test.html.twig');
22 | await documentCache.setText(document, code);
23 |
24 | const diagnostics = await diagnosticProvider.validateTree(document);
25 |
26 | assert.equal(diagnostics.length, 1);
27 | assert.deepEqual(diagnostics[0].range, createLengthRange(start, length));
28 | };
29 |
30 | test('empty output', () => {
31 | testDiagnostic('{{ }}');
32 | });
33 | test('empty if condition', () => testDiagnostic(`{% if %}{% endif %}`, 0, '{% if %}'.length));
34 | test('empty for element', () => testDiagnostic('{% for %}{% endfor %}', 0, '{% for %}'.length));
35 |
36 | test(
37 | 'empty if condition (multiline)',
38 | () => testDiagnostic(
39 | `{% if %}\n\n{% endif %}`,
40 | 0, '{% if %}'.length,
41 | ),
42 | );
43 |
44 | test(
45 | 'empty output in if block',
46 | () => testDiagnostic(
47 | '{% if true %}{{ }}{% endif %}',
48 | '{% if true %}'.length, '{{ }}'.length,
49 | ),
50 | );
51 | });
52 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/corpus/directives.txt:
--------------------------------------------------------------------------------
1 | ==================
2 | Empty template
3 | ==================
4 | ---
5 | (template)
6 |
7 | ==================
8 | Content one line
9 | ==================
10 | Lorem ipsum
11 | ---
12 | (template
13 | (content))
14 |
15 | ==================
16 | Content two line
17 | ==================
18 | Lorem ipsum
19 |
20 | ---
21 | (template
22 | (content))
23 |
24 | ==================
25 | Content with curly brace
26 | ==================
27 | Lorem { ipsum
28 | ---
29 | (template
30 | (content))
31 |
32 | ==================
33 | Empty comment
34 | ==================
35 | {# #}
36 | ---
37 | (template (comment))
38 |
39 | ==================
40 | comment single line
41 | ==================
42 | {# comment #}
43 | ---
44 | (template
45 | (comment))
46 |
47 | ==================
48 | comment multi line
49 | ==================
50 | {# note: disabled template because we no longer use this
51 | {% for user in users %}
52 | ...
53 | {% endfor %}
54 | #}
55 | ---
56 | (template
57 | (comment))
58 |
59 | ==================
60 | Lorem {# comment #} ipsum
61 | ==================
62 | Lorem {# comment #} ipsum
63 | ---
64 | (template
65 | (content)
66 | (comment)
67 | (content))
68 |
69 | ==================
70 | {# comment #} Lorem {# comment #}
71 | ==================
72 | {# comment #} Lorem {# comment #}
73 | ---
74 | (template
75 | (comment)
76 | (content)
77 | (comment))
78 |
79 | ===
80 | comment with hashes
81 | ===
82 | {# # safsaf
83 | # asfasf
84 | #}
85 |
86 | ==================
87 | output directive
88 | ==================
89 | {{ user }}
90 | ---
91 | (template
92 | (output
93 | (variable)))
94 |
95 | ==================
96 | empty output
97 | ==================
98 | {{}} {{ }} {{
99 |
100 | }}
101 | ---
102 | (template
103 | (output)
104 | (output)
105 | (output))
106 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/phpClasses.ts:
--------------------------------------------------------------------------------
1 | import { CompletionItem, CompletionItemKind } from 'vscode-languageserver/node';
2 | import { SyntaxNode } from 'web-tree-sitter';
3 | import { IPhpExecutor } from '../phpInterop/IPhpExecutor';
4 | import { primitives } from '../phpInterop/primitives';
5 | import { closestByPredicate } from '../utils/node';
6 |
7 | type VarVariable = {
8 | fullClassName: string,
9 | className: string,
10 | };
11 |
12 | const toCompletionItem = (variable: VarVariable): CompletionItem => ({
13 | label: variable.fullClassName,
14 | kind: CompletionItemKind.Class,
15 | detail: variable.className,
16 | insertText: variable.fullClassName,
17 | });
18 |
19 | const primitiveCompletions = [...primitives].map(
20 | primitive => toCompletionItem({ fullClassName: primitive, className: primitive }),
21 | );
22 |
23 | const typeNodes = new Set([
24 | 'primitive_type',
25 | 'qualified_name',
26 | 'incomplete_type',
27 | ]);
28 |
29 | // `\Foo|Bar & ^ #}`
30 | // `^` is a cursor
31 | const isAtTheEndOfVarDeclaration = (node: SyntaxNode) => node.type === 'comment_end' && node.parent!.type === 'var_declaration';
32 |
33 | export async function phpClasses(
34 | node: SyntaxNode,
35 | phpExecutor: IPhpExecutor | null,
36 | ): Promise {
37 | const typeNode = closestByPredicate(node, (n) => typeNodes.has(n.type))
38 | if (!typeNode && !isAtTheEndOfVarDeclaration(node)) return [];
39 |
40 | if (!phpExecutor) return primitiveCompletions;
41 |
42 | const classNames = await phpExecutor.getClassCompletion(typeNode?.text || '');
43 | const classes = classNames.map(fullClassName => {
44 | fullClassName = '\\' + fullClassName;
45 | const parts = fullClassName.split('\\');
46 | const className = parts[parts.length - 1];
47 | return toCompletionItem({ fullClassName, className });
48 | });
49 |
50 | return [
51 | ...primitiveCompletions,
52 | ...classes,
53 | ];
54 | }
55 |
--------------------------------------------------------------------------------
/packages/language-server/__tests__/utils.ts:
--------------------------------------------------------------------------------
1 | import { Range } from 'vscode-languageserver';
2 | import { Document } from '../src/documents/Document';
3 | import { initializeParser } from '../src/utils/parser';
4 | import * as path from 'path';
5 | import { LocalSymbolCollector } from '../src/symbols/LocalSymbolCollector';
6 | import { MockPhpExecutor } from './mocks';
7 | import { TypeResolver } from '../src/typing/TypeResolver';
8 | import { DocumentCache } from 'documents';
9 |
10 | type DocumentWithText = Document & { text: string };
11 |
12 | export const documentFromCode = async (code: string, uri = 'test://test.html.twig') => {
13 | const typeResolver = new TypeResolver(new MockPhpExecutor());
14 | return documentFromCodeWithTypeResolver(code, typeResolver, uri);
15 | };
16 |
17 | export const documentFromCodeWithTypeResolver = async (
18 | code: string,
19 | typeResolver: TypeResolver,
20 | uri = 'test://test.html.twig',
21 | ): Promise => {
22 | const document = new Document(uri);
23 | document.text = code;
24 | document.tree = (await initializeTestParser()).parse(document.text);
25 | document.locals = await new LocalSymbolCollector(
26 | document.tree.rootNode,
27 | typeResolver,
28 | ).collect();
29 |
30 | return document as DocumentWithText;
31 | };
32 |
33 | export const createDocumentCache = () => new DocumentCache({ name: '', uri: 'file:///' })
34 |
35 | export const initializeTestParser = async () => {
36 | const wasmPath = path.join(
37 | require.main!.path,
38 | '..',
39 | '..',
40 | 'tree-sitter-twig',
41 | 'tree-sitter-twig.wasm',
42 | );
43 | return await initializeParser(wasmPath);
44 | };
45 |
46 | const createRange = (start: number, end: number): Range => ({
47 | start: { character: start, line: 0 },
48 | end: { character: end, line: 0 },
49 | });
50 |
51 | export const createLengthRange = (start: number, length: number): Range => createRange(start, start + length);
52 |
--------------------------------------------------------------------------------
/packages/language-server/src/references/RenameProvider.ts:
--------------------------------------------------------------------------------
1 | import { Connection, PrepareRenameParams, Range, ReferenceParams, RenameParams } from 'vscode-languageserver/node';
2 | import { DocumentCache } from '../documents/DocumentCache';
3 | import { hasReferences } from '../symbols/types';
4 |
5 | export class RenameProvider {
6 | constructor(
7 | connection: Connection,
8 | private readonly documentCache: DocumentCache,
9 | ) {
10 | connection.onPrepareRename(this.onPrepareRename.bind(this));
11 | connection.onRenameRequest(this.onRenameRequest.bind(this));
12 | }
13 |
14 | async onPrepareRename(params: PrepareRenameParams) {
15 | const document = await this.documentCache.get(params.textDocument.uri);
16 | if (!document) {
17 | return;
18 | }
19 |
20 | const variable = document.variableAt(params.position);
21 | if (!variable) {
22 | return;
23 | }
24 |
25 | if (!hasReferences(variable)) {
26 | return;
27 | }
28 |
29 | return {
30 | range: variable.nameRange,
31 | placeholder: variable.name,
32 | };
33 | }
34 |
35 | async onRenameRequest(params: RenameParams) {
36 | const document = await this.documentCache.get(params.textDocument.uri);
37 | if (!document) {
38 | return;
39 | }
40 |
41 | const variable = document.variableAt(params.position);
42 | if (!variable) {
43 | return;
44 | }
45 |
46 | if (!hasReferences(variable)) {
47 | return;
48 | }
49 |
50 | return {
51 | changes: {
52 | [document.uri]: [
53 | {
54 | range: variable.nameRange,
55 | newText: params.newName,
56 | },
57 | ...variable.references.map(range => ({
58 | range,
59 | newText: params.newName,
60 | })),
61 | ],
62 | },
63 | };
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/packages/language-server/src/semantic-tokens/SemanticTokensProvider.ts:
--------------------------------------------------------------------------------
1 | import {
2 | SemanticTokensParams,
3 | SemanticTokens,
4 | SemanticTokensBuilder,
5 | Connection,
6 | } from 'vscode-languageserver';
7 | import { PreOrderCursorIterator } from '../utils/node';
8 | import { pointToPosition } from '../utils/position';
9 | import { semanticTokensLegend } from './tokens-provider';
10 | import { DocumentCache } from '../documents';
11 | import { TokenTypeResolver } from './TokenTypeResolver';
12 |
13 | export class SemanticTokensProvider {
14 | readonly #tokenTypeResolver: TokenTypeResolver;
15 |
16 | constructor(
17 | private readonly connection: Connection,
18 | private readonly documentCache: DocumentCache,
19 | ) {
20 | this.connection.languages.semanticTokens.on(
21 | this.serverRequestHandler.bind(this),
22 | );
23 |
24 | this.#tokenTypeResolver = new TokenTypeResolver(semanticTokensLegend);
25 | }
26 |
27 | async serverRequestHandler(params: SemanticTokensParams) {
28 | const semanticTokens: SemanticTokens = { data: [] };
29 | const document = await this.documentCache.get(params.textDocument.uri);
30 |
31 | if (!document) {
32 | return semanticTokens;
33 | }
34 |
35 | const tokensBuilder = new SemanticTokensBuilder();
36 | const nodes = new PreOrderCursorIterator(document.tree.walk());
37 |
38 | for (const node of nodes) {
39 | const tokenType = this.#tokenTypeResolver.resolve(node);
40 |
41 | if (tokenType === undefined) {
42 | continue;
43 | }
44 |
45 | const start = pointToPosition(node.startPosition);
46 | const lines = node.nodeText.split('\n');
47 | let lineNumber = start.line;
48 | let charNumber = start.character;
49 |
50 | for (const line of lines) {
51 | tokensBuilder.push(lineNumber++, charNumber, line.length, tokenType, 0);
52 | charNumber = 0;
53 | }
54 | }
55 |
56 | return tokensBuilder.build();
57 | }
58 | }
59 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
Twiggy
3 |
4 |
5 | VSCode Marketplace: [Twiggy](https://marketplace.visualstudio.com/items?itemName=moetelo.twiggy)
6 |
7 | This is a fork of [kaermorchen/twig-language-server (Modern Twig)](https://github.com/kaermorchen/twig-language-server).
8 |
9 | ## Definition
10 | 
11 | 
12 |
13 | ## Completion
14 | 
15 |
16 | > [!TIP]
17 | > For better completion in Symfony or Craft CMS, configure `twiggy.framework` and follow the extension output (`Twiggy Language Server`).
18 |
19 | ## Inlay hints
20 | 
21 |
22 | # Setup
23 | ## VS Code
24 | 1. Open Command Palette (`Ctrl+P`), type `ext install moetelo.twiggy` and press `Enter`.
25 | 1. For Symfony project, set `twiggy.phpExecutable` and `twiggy.symfonyConsolePath` in the VS Code settings.
26 | 1. Check the extension output (`Twiggy Language Server`) for errors.
27 |
28 | [Submit new issue](https://github.com/moetelo/twiggy/issues/new) if you have any problems or the feature you want is missing.
29 |
30 | ## Neovim
31 | Please refer to [this reply](https://github.com/moetelo/twiggy/issues/12#issuecomment-2044309054) and [the instructions from neovim/nvim-lspconfig](https://github.com/neovim/nvim-lspconfig/blob/master/doc/configs.md#twiggy_language_server).
32 |
33 | ## Sublime Text
34 | Follow [instructions](https://github.com/sublimelsp/LSP-twiggy) ready-to-use client implementation maintained by the community.
35 |
36 |
37 | # Development
38 | 1. Install [pnpm](https://pnpm.io/installation).
39 | 1. `pnpm install` in the project dir.
40 | 1. Press F5 in VS Code to start debugging.
41 |
42 | #### Monorepo
43 | - [Twiggy Language Server](packages/language-server/)
44 | - [VSCode Twig extension](packages/vscode/)
45 | - [tree-sitter-twig](packages/tree-sitter-twig/)
46 |
--------------------------------------------------------------------------------
/packages/language-server/src/phpInterop/PhpExecutor.ts:
--------------------------------------------------------------------------------
1 | import { PhpUtilPath } from '../twigEnvironment/PhpUtilPath';
2 | import { exec } from '../utils/exec';
3 | import { IPhpExecutor } from './IPhpExecutor';
4 | import { ReflectedType } from './ReflectedType';
5 |
6 | export class PhpExecutor implements IPhpExecutor {
7 | constructor(
8 | private readonly _phpExecutable: string | undefined,
9 | private readonly _workspaceDirectory: string,
10 | ) {
11 | if (!this._phpExecutable) {
12 | console.warn('`twiggy.phpExecutable` is not configured. Some features will be disabled.');
13 | }
14 | }
15 |
16 | async call(command: string, args: string[]) {
17 | if (!this._phpExecutable) {
18 | return null;
19 | }
20 |
21 | const result = await exec(this._phpExecutable, [
22 | command,
23 | ...args,
24 | ], {
25 | cwd: this._workspaceDirectory
26 | });
27 |
28 | if (result.stderr) {
29 | console.error(
30 | `Command "${command} ${args.join(' ')}" failed with following message:`,
31 | result.stderr,
32 | );
33 |
34 | console.error(
35 | "stdout:\n",
36 | result.stdout,
37 | "stderr:\n",
38 | result.stderr,
39 | );
40 | }
41 |
42 | return result;
43 | }
44 |
45 | async callJson(command: string, args: string[]): Promise {
46 | const result = await this.call(command, args);
47 | if (!result) {
48 | return null;
49 | }
50 |
51 | return JSON.parse(result.stdout) as TResult;
52 | }
53 |
54 | async getClassDefinition(className: string) {
55 | return await this.callJson<{ path: string | null }>(PhpUtilPath.getDefinitionPhp, [
56 | this._workspaceDirectory,
57 | `'${className}'`,
58 | ]);
59 | }
60 |
61 | async getClassCompletion(className: string) {
62 | return await this.callJson(PhpUtilPath.getCompletionPhp, [
63 | this._workspaceDirectory,
64 | `'${className}'`,
65 | ]) || [];
66 | }
67 |
68 | async reflectType(className: string) {
69 | return await this.callJson(PhpUtilPath.reflectType, [
70 | this._workspaceDirectory,
71 | `'${className}'`,
72 | ]);
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/packages/language-server/__tests__/mocks.ts:
--------------------------------------------------------------------------------
1 | import { IPhpExecutor } from '../src/phpInterop/IPhpExecutor';
2 | import { ReflectedType } from '../src/phpInterop/ReflectedType';
3 | import { EmptyEnvironment, IFrameworkTwigEnvironment } from '../src/twigEnvironment/IFrameworkTwigEnvironment';
4 |
5 | export const MockEnvironment: IFrameworkTwigEnvironment = {
6 | ...EmptyEnvironment,
7 | templateMappings: [ { namespace: '', directory: '' }],
8 | };
9 |
10 | export class MockPhpExecutor implements IPhpExecutor {
11 | static classMap: Record = {
12 | 'App\\SomeClass': {
13 | properties: [],
14 | methods: [
15 | {
16 | name: 'getPerson',
17 | type: 'App\\Person',
18 | parameters: [],
19 | },
20 | ],
21 | },
22 | 'App\\Person': {
23 | properties: [
24 | {
25 | name: 'name',
26 | type: 'string',
27 | },
28 | {
29 | name: 'age',
30 | type: 'int',
31 | },
32 | ],
33 | methods: [
34 | {
35 | name: 'getParent',
36 | type: 'App\\Person',
37 | parameters: [],
38 | },
39 | {
40 | name: 'getOtherClass',
41 | type: 'App\\OtherClass',
42 | parameters: [],
43 | },
44 | ],
45 | },
46 | 'App\\OtherClass': {
47 | properties: [
48 | {
49 | name: 'prop',
50 | type: 'int',
51 | }
52 | ],
53 | methods: [],
54 | },
55 | };
56 |
57 | call(command: string, args: string[]): Promise<{ stdout: string; stderr: string; } | null> {
58 | throw new Error('Method not implemented.');
59 | }
60 |
61 | callJson(command: string, args: string[]): Promise {
62 | throw new Error('Method not implemented.');
63 | }
64 |
65 | getClassDefinition(className: string): Promise<{ path: string | null; } | null> {
66 | throw new Error('Method not implemented.');
67 | }
68 |
69 | getClassCompletion(className: string): Promise {
70 | throw new Error('Method not implemented.');
71 | }
72 |
73 | reflectType(className: string): Promise {
74 | if (className.startsWith('\\')) {
75 | className = className.slice(1);
76 | }
77 |
78 | return Promise.resolve(MockPhpExecutor.classMap[className] || null);
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/packages/language-server/src/twigEnvironment/SymfonyTwigEnvironment.ts:
--------------------------------------------------------------------------------
1 | import { PhpExecutor } from '../phpInterop/PhpExecutor';
2 | import { isFile } from '../utils/files/fileStat';
3 | import { EmptyEnvironment, IFrameworkTwigEnvironment } from './IFrameworkTwigEnvironment';
4 | import { TwigEnvironmentArgs } from './TwigEnvironmentArgs';
5 | import { SymfonyTwigDebugJsonOutput, parseDebugTwigOutput } from './symfony/parseDebugTwigOutput';
6 | import { TwigEnvironment, RouteNameToPathRecord, TemplatePathMapping } from './types';
7 |
8 | export class SymfonyTwigEnvironment implements IFrameworkTwigEnvironment {
9 | #environment: TwigEnvironment | null = null;
10 | #routes: RouteNameToPathRecord = {};
11 |
12 | #symfonyConsolePath: string | undefined;
13 |
14 | constructor(private readonly _phpExecutor: PhpExecutor) {
15 | }
16 |
17 | get environment() {
18 | return this.#environment;
19 | }
20 |
21 | get routes() {
22 | return this.#routes;
23 | }
24 |
25 | get templateMappings(): TemplatePathMapping[] {
26 | return this.#environment?.LoaderPaths?.length
27 | ? this.#environment.LoaderPaths
28 | : EmptyEnvironment.templateMappings;
29 | }
30 |
31 | async refresh({ symfonyConsolePath }: TwigEnvironmentArgs): Promise {
32 | this.#symfonyConsolePath = symfonyConsolePath;
33 |
34 | if (!symfonyConsolePath) {
35 | console.warn('Symfony console path is not set');
36 | }
37 |
38 | if (!await isFile(symfonyConsolePath)) {
39 | console.warn(`Symfony console path "${symfonyConsolePath}" does not exist`);
40 | }
41 |
42 | const [environment, routes] = await Promise.all([
43 | this.#loadEnvironment(),
44 | this.#loadRoutes(),
45 | ]);
46 | this.#environment = environment;
47 | this.#routes = routes;
48 | }
49 |
50 | async #loadEnvironment(): Promise {
51 | const result = await this.#runSymfonyCommand('debug:twig');
52 |
53 | if (!result) {
54 | return null;
55 | }
56 |
57 | return parseDebugTwigOutput(result);
58 | }
59 |
60 | async #loadRoutes(): Promise {
61 | const result = await this.#runSymfonyCommand('debug:router');
62 | return result || {};
63 | }
64 |
65 | async #runSymfonyCommand(
66 | command: string,
67 | ): Promise {
68 | if (!this.#symfonyConsolePath) {
69 | return null;
70 | }
71 |
72 | return await this._phpExecutor.callJson(this.#symfonyConsolePath, [
73 | command,
74 | '--format',
75 | 'json',
76 | ]);
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/packages/tree-sitter-twig/corpus/var-comments.txt:
--------------------------------------------------------------------------------
1 | ==================
2 | var declaration: int
3 | ==================
4 | {# @var someVar int #}
5 | ---
6 | (template
7 | (var_declaration
8 | (variable)
9 | (primitive_type)))
10 |
11 | ==================
12 | var declaration: qualified_name
13 | ==================
14 | {# @var someVar \Foo\Bar\Baz #}
15 | ---
16 | (template
17 | (var_declaration
18 | (variable)
19 | (qualified_name
20 | (namespace
21 | (php_identifier)
22 | (php_identifier))
23 | (php_identifier))))
24 |
25 | ==================
26 | var declaration: union
27 | ==================
28 | {# @var someVar int | \Foo\Bar #}
29 | ---
30 | (template
31 | (var_declaration
32 | (variable)
33 | (union_type
34 | (primitive_type)
35 | (qualified_name
36 | (namespace
37 | (php_identifier))
38 | (php_identifier)))))
39 |
40 |
41 | ==================
42 | var declaration: intersection
43 | ==================
44 | {# @var someVar int & string #}
45 | ---
46 | (template
47 | (var_declaration
48 | (variable)
49 | (intersection_type
50 | (primitive_type)
51 | (primitive_type))))
52 |
53 |
54 | ==================
55 | var declaration: incomplete primitive
56 | ==================
57 | {# @var someVar str #}
58 | ---
59 | (template
60 | (var_declaration
61 | (variable)
62 | (incomplete_type)))
63 |
64 | ==================
65 | var declaration: incomplete_qualified_name
66 | ==================
67 | {# @var someVar \App\Something\ #}
68 | ---
69 | (template
70 | (var_declaration
71 | (variable)
72 | (incomplete_type
73 | (namespace
74 | (php_identifier)
75 | (php_identifier)))))
76 |
77 | ==================
78 | var declaration: incomplete_qualified_name - backslash \
79 | ==================
80 | {# @var someVar \ #}
81 | ---
82 | (template
83 | (var_declaration
84 | (variable)
85 | (incomplete_type
86 | (namespace))))
87 |
88 | ==================
89 | var declaration: array_type
90 | ==================
91 | {# @var items \Foo\Bar[] #}
92 | ---
93 | (template
94 | (var_declaration
95 | (variable)
96 | (array_type
97 | (qualified_name
98 | (namespace
99 | (php_identifier))
100 | (php_identifier)))))
101 |
102 | ==================
103 | var declaration: incomplete_qualified_name in union
104 | ==================
105 | {# @var someVar \App\SomeClass | \App\ #}
106 | ---
107 | (template
108 | (var_declaration
109 | (variable)
110 | (union_type
111 | (qualified_name
112 | (namespace
113 | (php_identifier))
114 | (php_identifier))
115 | (incomplete_type
116 | (namespace
117 | (php_identifier)))
118 | )))
119 |
--------------------------------------------------------------------------------
/packages/language-server/__tests__/deepestAt.test.ts:
--------------------------------------------------------------------------------
1 | import { test, before, describe } from 'node:test'
2 | import * as assert from 'node:assert/strict'
3 | import { documentFromCode, initializeTestParser } from './utils';
4 |
5 | describe('Document.deepestAt', () => {
6 | before(initializeTestParser);
7 |
8 | test('cursor between nodes takes the node before the cursor', async () => {
9 | const document = await documentFromCode(`{{hello}}{{world}}`);
10 |
11 | const node = document.deepestAt({ line: 0, character: `{{hello}}`.length })!;
12 |
13 | assert.equal(node.type, 'embedded_end');
14 | });
15 |
16 | test('cursor after the space char takes the next node because its startIndex is eq to space idx', async () => {
17 | const document = await documentFromCode(`{{ hello }}`);
18 | const character = `{{ `.length;
19 |
20 | const node = document.deepestAt({ line: 0, character })!;
21 |
22 | assert.equal(node.type, 'variable');
23 | assert.equal(node.startIndex, character);
24 | });
25 |
26 | test('iterate tokens', async () => {
27 | const document = await documentFromCode(`{%set variable = 123%}{{variable}}`);
28 | const expectedNodes = [
29 | { type: 'embedded_begin', start: 0, nodeText: '{%' },
30 | { type: 'keyword', start: 3, nodeText: 'set' },
31 | { type: 'variable', start: 7, nodeText: 'variable' },
32 | { type: '=', start: 16, nodeText: '=' },
33 | { type: 'number', start: 18, nodeText: '123' },
34 | { type: 'embedded_end', start: 21, nodeText: '%}' },
35 |
36 | { type: 'embedded_begin', start: 23, nodeText: '{{' },
37 | { type: 'variable', start: 25, nodeText: 'variable' },
38 | { type: 'embedded_end', start: 33, nodeText: '}}' },
39 | ] as const;
40 |
41 | for (const { type, start, nodeText } of expectedNodes) {
42 | const end = start + nodeText.length;
43 |
44 | for (let character = start; character < end; character++) {
45 | const node = document.deepestAt({ line: 0, character })!;
46 | assert.equal(node.type, type);
47 | assert.equal(node.text, nodeText);
48 | }
49 | }
50 | });
51 | });
52 |
53 |
54 | describe('Document.deepestAt for incomplete nodes', () => {
55 | before(initializeTestParser);
56 |
57 | test('empty output', async () => {
58 | const document = await documentFromCode(`{{ }}`);
59 |
60 | const node = document.deepestAt({ line: 0, character: `{{ `.length })!;
61 |
62 | assert.equal(node.type, 'output');
63 | });
64 |
65 | test('empty if condition', async () => {
66 | const document = await documentFromCode(`{% if %}{% endif %}`);
67 |
68 | const node = document.deepestAt({ line: 0, character: `{% if`.length })!;
69 |
70 | assert.equal(node.parent!.type, 'if');
71 | });
72 | });
73 |
--------------------------------------------------------------------------------
/packages/language-server/src/symbols/nodeToSymbolMapping.ts:
--------------------------------------------------------------------------------
1 | import {
2 | FunctionArgument,
3 | TwigBlock,
4 | TwigImport,
5 | TwigMacro,
6 | TwigVariableDeclaration,
7 | } from './types';
8 | import { getNodeRange, getStringNodeValue } from '../utils/node';
9 | import { SyntaxNode } from 'web-tree-sitter';
10 |
11 | export function toBlock(node: SyntaxNode): Omit {
12 | const nameNode = node.childForFieldName('name')!;
13 |
14 | return {
15 | name: nameNode.text,
16 | range: getNodeRange(node),
17 | nameRange: getNodeRange(nameNode),
18 | };
19 | }
20 |
21 | export function toVariable(node: SyntaxNode): TwigVariableDeclaration {
22 | const variableNode = node.childForFieldName('variable')!;
23 |
24 | const type = node.type === 'set_block'
25 | ? 'string'
26 | : node.childForFieldName('type')?.text;
27 |
28 | return {
29 | name: variableNode.text,
30 | nameRange: getNodeRange(variableNode),
31 | value: node.childForFieldName('value')?.text,
32 | type,
33 | reflectedType: null,
34 | range: getNodeRange(node),
35 | references: [],
36 | };
37 | }
38 |
39 | export function toMacro(node: SyntaxNode): Omit {
40 | const nameNode = node.childForFieldName('name')!;
41 | const argumentsNode = node.childForFieldName('arguments');
42 |
43 | const variableArgs = argumentsNode?.descendantsOfType('variable')
44 | .map((argumentNode): FunctionArgument => ({
45 | name: argumentNode.text,
46 | nameRange: getNodeRange(argumentNode),
47 | range: getNodeRange(argumentNode),
48 | })) || [];
49 |
50 | const namedArgs = argumentsNode
51 | ?.descendantsOfType('named_argument')
52 | .map((argumentNode): FunctionArgument => {
53 | const argNameNode = argumentNode.childForFieldName('key')!;
54 | const value = argumentNode.childForFieldName('value')!.text;
55 |
56 | return {
57 | name: argNameNode.text,
58 | nameRange: getNodeRange(argNameNode),
59 | value,
60 | range: getNodeRange(argumentNode),
61 | };
62 | }) || [];
63 |
64 | return {
65 | name: nameNode.text,
66 | nameRange: getNodeRange(nameNode),
67 | args: [...variableArgs, ...namedArgs],
68 | range: getNodeRange(node),
69 | };
70 | }
71 |
72 | function resolveImportPath(pathNode: SyntaxNode) {
73 | if (pathNode.type === 'string') {
74 | return getStringNodeValue(pathNode);
75 | }
76 |
77 | return undefined;
78 | }
79 |
80 | export function toImport(node: SyntaxNode): TwigImport {
81 | const pathNode = node.childForFieldName('expr')!;
82 | const aliasNode = node.childForFieldName('variable')!;
83 |
84 | return {
85 | name: aliasNode.text,
86 | path: resolveImportPath(pathNode),
87 | range: getNodeRange(node),
88 | nameRange: getNodeRange(aliasNode),
89 | references: [],
90 | };
91 | }
92 |
--------------------------------------------------------------------------------
/packages/language-server/phpUtils/reflectType.php:
--------------------------------------------------------------------------------
1 | findFile($INSTANCE_CLASS);
18 | require_once $phpFilePath;
19 |
20 | $refClass = new \ReflectionClass($INSTANCE_CLASS);
21 |
22 | $properties = $refClass->getProperties(\ReflectionProperty::IS_PUBLIC);
23 | $methods = $refClass->getMethods(\ReflectionMethod::IS_PUBLIC);
24 |
25 | const GETTER_PREFIX = 'get';
26 | function getPropertyName(string $getterName): string {
27 | return lcfirst(
28 | substr($getterName, strlen(GETTER_PREFIX)),
29 | );
30 | }
31 |
32 | function typeToString(\ReflectionType $type): string {
33 | if ($type instanceof \ReflectionNamedType) {
34 | return $type->getName();
35 | }
36 |
37 | if ($type instanceof \ReflectionUnionType) {
38 | return implode('|', array_map(
39 | fn(\ReflectionNamedType $type) => $type->getName(),
40 | $type->getTypes(),
41 | ));
42 | }
43 |
44 | if ($type instanceof \ReflectionIntersectionType) {
45 | return implode('&', array_map(
46 | fn(\ReflectionNamedType $type) => $type->getName(),
47 | $type->getTypes(),
48 | ));
49 | }
50 |
51 | throw new \RuntimeException('Unknown type');
52 | }
53 |
54 | $completionProperties = [];
55 | $completionMethods = [];
56 | /** @var \ReflectionMethod $method */
57 | foreach ($methods as $method) {
58 | if ($method->isConstructor() || $method->isDestructor()) {
59 | continue;
60 | }
61 |
62 | $methodName = $method->getName();
63 | if (str_starts_with($methodName, '__')) {
64 | continue;
65 | }
66 |
67 | $parameters = $method->getParameters();
68 |
69 | if (str_starts_with($methodName, GETTER_PREFIX) && count($parameters) === 0) {
70 | $propertyName = getPropertyName($methodName);
71 | $completionProperties[] = [
72 | 'name' => $propertyName,
73 | 'type' => $method->getReturnType()?->getName() ?? '',
74 | ];
75 | }
76 |
77 | $completionMethods[] = [
78 | 'name' => $methodName,
79 | 'type' => $method->hasReturnType() ? typeToString($method->getReturnType()) : '',
80 | 'parameters' => array_map(
81 | fn(\ReflectionParameter $parameter) => [
82 | 'name' => $parameter->getName(),
83 | 'type' => $parameter->hasType() ? typeToString($parameter->getType()) : '',
84 | 'isOptional' => $parameter->isOptional(),
85 | 'isVariadic' => $parameter->isVariadic(),
86 | ],
87 | $parameters,
88 | ),
89 | ];
90 | }
91 |
92 | $result = [
93 | 'properties' => $completionProperties,
94 | 'methods' => $completionMethods,
95 | ];
96 |
97 | echo json_encode($result, JSON_PRETTY_PRINT) . PHP_EOL;
98 |
--------------------------------------------------------------------------------
/packages/language-server/src/inlayHints/InlayHintProvider.ts:
--------------------------------------------------------------------------------
1 | import { Connection, InlayHint, InlayHintKind, InlayHintParams } from 'vscode-languageserver';
2 | import { PreOrderCursorIterator, getNodeRange } from '../utils/node';
3 | import { parseFunctionCall } from '../utils/node/parseFunctionCall';
4 | import { SyntaxNode } from 'web-tree-sitter';
5 | import { InlayHintSettings } from '../configuration/LanguageServerSettings';
6 | import { DocumentCache } from '../documents';
7 |
8 | const toInlayHint = (node: SyntaxNode): InlayHint => {
9 | const range = getNodeRange(node);
10 | const nameNode = node.childForFieldName('name')!;
11 |
12 | return {
13 | position: range.end,
14 | label: `{% ${node.type} ${nameNode.text} %}`,
15 | paddingLeft: true,
16 | kind: InlayHintKind.Type,
17 | };
18 | };
19 |
20 | export class InlayHintProvider {
21 | static readonly defaultSettings: InlayHintSettings = {
22 | macro: true,
23 | block: true,
24 | macroArguments: true,
25 | };
26 |
27 | settings = InlayHintProvider.defaultSettings;
28 |
29 | constructor(
30 | connection: Connection,
31 | private readonly documentCache: DocumentCache,
32 | ) {
33 | connection.languages.inlayHint.on(
34 | this.onInlayHint.bind(this),
35 | );
36 | }
37 |
38 | async onInlayHint(params: InlayHintParams): Promise {
39 | const { block, macro, macroArguments } = this.settings;
40 | if (!block && !macro && !macroArguments) return;
41 |
42 | const document = await this.documentCache.get(params.textDocument.uri);
43 |
44 | if (!document) {
45 | return;
46 | }
47 |
48 | const inlayHints: InlayHint[] = [];
49 |
50 | const nodes = new PreOrderCursorIterator(document.tree.walk());
51 | for (const node of nodes) {
52 | if (macroArguments && node.nodeType === 'call_expression') {
53 | const calledFunc = parseFunctionCall(node.currentNode);
54 | if (!calledFunc || !calledFunc.object || !calledFunc.args.length) continue;
55 |
56 | const importedDocument = await this.documentCache.resolveImport(document, calledFunc.object);
57 | if (!importedDocument) continue;
58 |
59 | const macro = importedDocument.locals.macro.find(macro => macro.name === calledFunc.name);
60 | if (!macro) continue;
61 |
62 | const hints = calledFunc.args
63 | .slice(0, macro.args.length)
64 | .map((arg, i): InlayHint => ({
65 | position: arg.range.start,
66 | label: `${macro.args[i].name}:`,
67 | kind: InlayHintKind.Parameter,
68 | paddingRight: true,
69 | }));
70 |
71 | inlayHints.push(...hints);
72 | }
73 |
74 | if (
75 | (block && node.nodeType === 'block' || macro && node.nodeType === 'macro')
76 | && node.startPosition.row !== node.endPosition.row
77 | ) {
78 | const hint = toInlayHint(node.currentNode);
79 | inlayHints.push(hint);
80 | }
81 | }
82 |
83 | return inlayHints;
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/packages/vscode/build/index.mjs:
--------------------------------------------------------------------------------
1 | // @ts-check
2 |
3 | import { execSync } from 'node:child_process';
4 | import * as esbuild from 'esbuild';
5 | import { cpSync as cp, rmSync as rm, existsSync } from 'node:fs';
6 | import copyPlugin from 'esbuild-plugin-copy';
7 |
8 | const isDev = process.argv.includes('--dev');
9 |
10 | const grammarWasmPath = '../tree-sitter-twig/tree-sitter-twig.wasm';
11 |
12 | /**
13 | * @type {esbuild.Plugin}
14 | */
15 | const triggerVscodeDebug = {
16 | name: 'trigger-vscode-problem-matcher-debug',
17 | setup(build) {
18 | let isFirstBuild = true;
19 |
20 | build.onEnd(_result => {
21 | if (isFirstBuild) {
22 | console.log('[watch] build finished, watching for changes...');
23 | isFirstBuild = false;
24 | }
25 | });
26 | },
27 | };
28 |
29 | /**
30 | * @type {esbuild.Plugin}
31 | */
32 | const watchLogPlugin = {
33 | name: 'watch-log-plugin',
34 | setup(build) {
35 | let start = performance.now();
36 |
37 | build.onStart(() => {
38 | start = performance.now();
39 | });
40 |
41 | build.onEnd(_result => {
42 | const end = performance.now();
43 | const time = end - start;
44 | console.log(`[${new Date().toLocaleTimeString()}] ${time.toFixed(1)}ms`);
45 | });
46 | },
47 | };
48 |
49 | const buildOptions = /** @type {const} @satisfies {esbuild.BuildOptions} */ ({
50 | entryPoints: {
51 | extension: './src/extension.ts',
52 | server: '../language-server/src/index.ts',
53 | },
54 | bundle: true,
55 | external: [
56 | 'vscode',
57 | ],
58 | outdir: './dist',
59 | format: 'cjs',
60 | platform: 'node',
61 | metafile: process.argv.includes('--metafile'),
62 | minify: !isDev,
63 | sourcemap: isDev,
64 | assetNames: 'assets/[name]-[hash].[ext]',
65 | logLevel: isDev ? 'error' : 'info',
66 | define: {
67 | 'process.env.NODE_ENV': !isDev ? '"production"' : 'undefined',
68 | '__DEBUG__': JSON.stringify(isDev),
69 | },
70 | plugins: [
71 | ...(isDev ? [ triggerVscodeDebug, watchLogPlugin ] : []),
72 | copyPlugin({
73 | resolveFrom: 'cwd',
74 | assets: [
75 | { watch: isDev, from: '../language-server/node_modules/web-tree-sitter/tree-sitter.wasm', to: './dist/' },
76 | { watch: isDev, from: grammarWasmPath, to: './dist/' },
77 | { watch: isDev, from: '../language-server/phpUtils/**/*', to: './dist/phpUtils/' },
78 | ],
79 | }),
80 | ],
81 | });
82 |
83 | async function main() {
84 | rm('./dist', { force: true, recursive: true });
85 | rm('../language-server/dist', { force: true, recursive: true });
86 |
87 | const grammarWasmExists = existsSync(grammarWasmPath);
88 | console.info({ grammarWasmExists });
89 |
90 | if (!grammarWasmExists) {
91 | console.info('Building wasm grammar. This may take a while.');
92 | execSync('pnpm --workspace-root run build-grammar-wasm', { stdio: 'inherit' })
93 | }
94 |
95 | const ctx = await esbuild.context(buildOptions);
96 | if (isDev) {
97 | await ctx.watch();
98 | return;
99 | }
100 |
101 | await ctx.rebuild();
102 | await ctx.dispose();
103 |
104 | cp('./dist', '../language-server/dist', { recursive: true });
105 | rm('../language-server/dist/extension.js');
106 | }
107 |
108 | main();
109 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/template-paths.ts:
--------------------------------------------------------------------------------
1 | import { CompletionItem, CompletionItemKind, Position, Range } from 'vscode-languageserver';
2 | import path from 'path';
3 | import { SyntaxNode } from 'web-tree-sitter';
4 | import {
5 | templateUsingFunctions,
6 | templateUsingStatements,
7 | } from '../constants/template-usage';
8 | import getTwigFiles from '../utils/getTwigFiles';
9 | import { TemplatePathMapping } from '../twigEnvironment/types';
10 | import { getStringNodeValue } from 'utils/node';
11 |
12 | export async function templatePaths(
13 | cursorNode: SyntaxNode,
14 | position: Position,
15 | workspaceFolderDirectory: string,
16 | templateMappings: TemplatePathMapping[],
17 | ): Promise {
18 | if (cursorNode.type !== 'string') {
19 | return [];
20 | }
21 |
22 | let node = cursorNode.parent;
23 |
24 | if (!node) {
25 | return [];
26 | }
27 |
28 | // This case for array or ternary wrappers
29 | // ['template.html']
30 | // ajax ? 'ajax.html' : 'not_ajax.html'
31 | if (['array', 'ternary'].includes(node.type)) {
32 | node = node.parent;
33 | }
34 |
35 | if (!node) {
36 | return [];
37 | }
38 |
39 | if (
40 | // {% import "forms.html" as forms %}
41 | // {% from "macros.twig" import hello %}
42 | // {% include 'template.html' %}
43 | // {% extends 'template.html' %}
44 | // {% use 'template.html' %}
45 | templateUsingStatements.includes(node.type) ||
46 | // {{ include('template.html') }}
47 | // {{ source('template.html') }}
48 | (node.type === 'arguments' &&
49 | templateUsingFunctions.includes(
50 | node.parent?.childForFieldName('name')?.text || '',
51 | )) ||
52 | // {{ block("title", "common_blocks.twig") }}
53 | (node.type === 'arguments'
54 | && node.parent?.childForFieldName('name')?.text === 'block'
55 | && node.namedChildren[1]
56 | && cursorNode?.equals(node.namedChildren[1]))
57 | ) {
58 | const completions: CompletionItem[] = [];
59 |
60 | const nodeStringStart = cursorNode.startPosition.column + 1;
61 | const nodeStringEnd = cursorNode.endPosition.column - 1;
62 | const range: Range = {
63 | start: { line: position.line, character: nodeStringStart },
64 | end: { line: position.line, character: nodeStringEnd },
65 | };
66 |
67 | const searchText = getStringNodeValue(cursorNode)
68 | .substring(0, position.character - nodeStringStart);
69 |
70 | for (const { namespace, directory } of templateMappings) {
71 | const templatesDirectory = path.resolve(workspaceFolderDirectory, directory)
72 |
73 | for (const twigPath of await getTwigFiles(directory)) {
74 | const relativePathToTwigFromTemplatesDirectory = path.posix.relative(templatesDirectory, twigPath);
75 | const includePath = path.posix.join(namespace, relativePathToTwigFromTemplatesDirectory)
76 |
77 | if (searchText === '' || includePath.startsWith(searchText)) {
78 | completions.push({
79 | label: includePath,
80 | kind: CompletionItemKind.File,
81 | textEdit: {
82 | range,
83 | newText: includePath,
84 | },
85 | });
86 | }
87 | }
88 | }
89 |
90 | return completions;
91 | }
92 |
93 | return [];
94 | }
95 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/CompletionProvider.ts:
--------------------------------------------------------------------------------
1 | import { CompletionParams, Connection, WorkspaceFolder } from 'vscode-languageserver/node';
2 | import { templatePaths } from './template-paths';
3 | import { globalVariables } from './global-variables';
4 | import { localVariables } from './local-variables';
5 | import { functions } from './functions';
6 | import { filters } from './filters';
7 | import { forLoop } from './for-loop';
8 | import { EmptyEnvironment, IFrameworkTwigEnvironment } from '../twigEnvironment';
9 | import { variableProperties } from './variableProperties';
10 | import { snippets } from './snippets';
11 | import { keywords } from './keywords';
12 | import { DocumentCache } from '../documents';
13 | import { symfonyRouteNames } from './routes';
14 | import { phpClasses } from './phpClasses';
15 | import { documentUriToFsPath } from '../utils/uri';
16 | import { IPhpExecutor } from '../phpInterop/IPhpExecutor';
17 | import { ExpressionTypeResolver } from '../typing/ExpressionTypeResolver';
18 | import { ITypeResolver } from '../typing/ITypeResolver';
19 | import { IExpressionTypeResolver } from '../typing/IExpressionTypeResolver';
20 |
21 | export class CompletionProvider {
22 | #symfonyRouteNames: string[] = [];
23 | #environment: IFrameworkTwigEnvironment = EmptyEnvironment;
24 | workspaceFolderPath: string;
25 | #phpExecutor: IPhpExecutor | null = null;
26 | #expressionTypeResolver: IExpressionTypeResolver | null = null;
27 |
28 | constructor(
29 | private readonly connection: Connection,
30 | private readonly documentCache: DocumentCache,
31 | workspaceFolder: WorkspaceFolder,
32 | ) {
33 | this.connection.onCompletion(this.onCompletion.bind(this));
34 | this.connection.onCompletionResolve(item => item);
35 | this.workspaceFolderPath = documentUriToFsPath(workspaceFolder.uri);
36 | }
37 |
38 | refresh(
39 | environment: IFrameworkTwigEnvironment,
40 | phpExecutor: IPhpExecutor | null,
41 | typeResolver: ITypeResolver | null,
42 | ) {
43 | this.#environment = environment;
44 | this.#symfonyRouteNames = Object.keys(environment.routes);
45 | this.#phpExecutor = phpExecutor;
46 | this.#expressionTypeResolver = typeResolver ? new ExpressionTypeResolver(typeResolver) : null;
47 | }
48 |
49 | async onCompletion(params: CompletionParams) {
50 | const document = await this.documentCache.get(params.textDocument.uri);
51 | if (!document) {
52 | return;
53 | }
54 |
55 | const cursorNode = document.deepestAt(params.position);
56 | if (!cursorNode) {
57 | return;
58 | }
59 |
60 | const { environment } = this.#environment;
61 |
62 | return [
63 | ...snippets(cursorNode),
64 | ...keywords(cursorNode),
65 | ...localVariables(document, cursorNode),
66 | ...forLoop(cursorNode),
67 | ...globalVariables(cursorNode, environment?.Globals || []),
68 | ...functions(cursorNode, environment?.Functions || []),
69 | ...filters(cursorNode, environment?.Filters || []),
70 | ...symfonyRouteNames(cursorNode, this.#symfonyRouteNames),
71 | ...await phpClasses(cursorNode, this.#phpExecutor),
72 | ...await variableProperties(document, this.documentCache, cursorNode, this.#expressionTypeResolver, params.position),
73 | ...await templatePaths(
74 | cursorNode,
75 | params.position,
76 | this.workspaceFolderPath,
77 | this.#environment.templateMappings,
78 | ),
79 | ];
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/packages/language-server/src/phpInterop/TwigCodeStyleFixer.ts:
--------------------------------------------------------------------------------
1 | import { Diagnostic, DiagnosticSeverity, DocumentUri, Range } from 'vscode-languageserver';
2 | import { IPhpExecutor } from './IPhpExecutor';
3 | import { isFile } from 'utils/files/fileStat';
4 | import path from 'node:path';
5 | import { documentUriToFsPath, toDocumentUri } from 'utils/uri';
6 |
7 | const severityToDiagnosticSeverity = {
8 | error: DiagnosticSeverity.Warning,
9 | notice: DiagnosticSeverity.Information,
10 | warning: DiagnosticSeverity.Warning,
11 | };
12 |
13 | const enum TwigCodeStyleAction {
14 | Lint = 'lint',
15 | Fix = 'fix',
16 | }
17 |
18 | export class TwigCodeStyleFixer {
19 | #executablePath: string;
20 |
21 | constructor(
22 | readonly _phpExecutor: IPhpExecutor | null,
23 | readonly _workspaceDirectory: string,
24 | ) {
25 | this.#executablePath = path.join(this._workspaceDirectory, 'vendor/bin/twig-cs-fixer');
26 | }
27 |
28 | async #call(action: TwigCodeStyleAction, uri: DocumentUri | '' = '') {
29 | if (!this._phpExecutor || !await isFile(this.#executablePath)) {
30 | return null;
31 | }
32 |
33 | const args = [action, '-r', 'github'];
34 | if (uri) {
35 | args.push(
36 | path.relative(this._workspaceDirectory, documentUriToFsPath(uri)),
37 | );
38 | }
39 |
40 | const lintResult = await this._phpExecutor.call(this.#executablePath, args);
41 | if (!lintResult?.stdout) {
42 | return null;
43 | }
44 |
45 | const uriToDiagnostics = new Map();
46 |
47 | const reportLines = lintResult.stdout.split('\n');
48 | for (const reportLine of reportLines) {
49 | // ::error file=templates/template.html.twig,line=20,col=81::Expecting 0 whitespace after "|"; found 1.
50 | const match = reportLine.match(/::(error|notice|warning) file=(.*),line=(.*),col=(.*):(.*)/);
51 | if (!match) {
52 | continue;
53 | }
54 |
55 | const [severity, filePath, line, col, message] = match.slice(1) as [
56 | keyof typeof severityToDiagnosticSeverity,
57 | ...string[],
58 | ];
59 |
60 | const fullFilePath = path.join(this._workspaceDirectory, filePath);
61 | const uri = toDocumentUri(fullFilePath);
62 |
63 | if (!uriToDiagnostics.has(uri)) {
64 | uriToDiagnostics.set(uri, []);
65 | }
66 |
67 | const diagnostics = uriToDiagnostics.get(uri)!;
68 | diagnostics.push({
69 | message,
70 | severity: severityToDiagnosticSeverity[severity],
71 | range: Range.create(
72 | parseInt(line) - 1, parseInt(col) - 1,
73 | parseInt(line) - 1, parseInt(col),
74 | ),
75 | });
76 | }
77 |
78 | return uriToDiagnostics;
79 | }
80 |
81 | async lint(uri: DocumentUri) {
82 | const result = await this.#call(TwigCodeStyleAction.Lint, uri);
83 | return result?.get(uri)! || [];
84 | }
85 |
86 | async fix(uri: DocumentUri) {
87 | await this.#call(TwigCodeStyleAction.Fix, uri);
88 | }
89 |
90 | async lintWorkspace() {
91 | const result = await this.#call(TwigCodeStyleAction.Lint);
92 | if (!result) {
93 | return [];
94 | }
95 |
96 | return [...result.entries()].map(([uri, diagnostics]) => ({
97 | uri,
98 | diagnostics,
99 | }));
100 | }
101 | }
102 |
--------------------------------------------------------------------------------
/packages/language-server/src/documents/Document.ts:
--------------------------------------------------------------------------------
1 | import { DocumentUri, Position } from 'vscode-languageserver';
2 | import Parser, { SyntaxNode } from 'web-tree-sitter';
3 | import { LocalSymbol, LocalSymbolInformation, TwigBlock } from '../symbols/types';
4 | import { documentUriToFsPath } from '../utils/uri';
5 | import { pointToPosition, rangeContainsPosition } from '../utils/position';
6 | import { getNodeRange } from '../utils/node';
7 |
8 | class TreeNotParsedError extends Error {
9 | get message() {
10 | return 'Document tree is not parsed yet. File: ' + documentUriToFsPath(this.uri);
11 | }
12 |
13 | constructor(readonly uri: DocumentUri) {
14 | super();
15 | }
16 | }
17 |
18 | export class Document {
19 | readonly uri: DocumentUri;
20 |
21 | text: string | null = null;
22 |
23 | #tree?: Parser.Tree;
24 | #locals?: LocalSymbolInformation;
25 |
26 | constructor(uri: DocumentUri) {
27 | this.uri = uri;
28 | }
29 |
30 | get tree() {
31 | if (!this.#tree) throw new TreeNotParsedError(this.uri);
32 |
33 | return this.#tree;
34 | }
35 |
36 | set tree(tree: Parser.Tree) {
37 | this.#tree = tree;
38 | }
39 |
40 | get locals() {
41 | if (!this.#locals) throw new TreeNotParsedError(this.uri);
42 |
43 | return this.#locals;
44 | }
45 |
46 | set locals(locals: LocalSymbolInformation) {
47 | this.#locals = locals;
48 | }
49 |
50 | getBlock(name: string): TwigBlock | undefined {
51 | const symbol = this.locals.block.find((s) => s.name === name);
52 | if (symbol) return symbol;
53 |
54 | return this.locals.block
55 | .flatMap((b) => b.symbols.block)
56 | .find((s) => s.name === name);
57 | }
58 |
59 | getScopeAt(pos: Position): LocalSymbolInformation {
60 | const scopes = [
61 | ...this.locals.macro,
62 | ...this.locals.block,
63 | ];
64 |
65 | return scopes.find((scope) => rangeContainsPosition(scope.range, pos))?.symbols
66 | || this.locals;
67 | }
68 |
69 | getLocalsAt(cursorPosition: Position): LocalSymbol[] {
70 | const blocks = this.locals.block.filter(x => rangeContainsPosition(x.range, cursorPosition));
71 | const macroses = this.locals.macro.filter(x => rangeContainsPosition(x.range, cursorPosition));
72 |
73 | const scopedVariables = [ ...macroses, ...blocks ].flatMap(x => [ ...x.symbols.variable, ...x.symbols.imports ]);
74 |
75 | return [
76 | ...scopedVariables,
77 | ...macroses.flatMap(x => x.args),
78 | ...this.locals.variable,
79 | ...this.locals.imports,
80 | ];
81 | }
82 |
83 | variableAt(pos: Position): LocalSymbol | undefined {
84 | const cursorNode = this.deepestAt(pos);
85 | if (!cursorNode || cursorNode.type !== 'variable') {
86 | return;
87 | }
88 |
89 | const variableName = cursorNode.text;
90 | const cursorPosition = pointToPosition(cursorNode.startPosition);
91 | const scopedVariables = this.getLocalsAt(cursorPosition);
92 | const variable = scopedVariables.find((x) => x.name === variableName);
93 |
94 | return variable;
95 | }
96 |
97 | deepestAt(pos: Position): SyntaxNode {
98 | let node = this.tree.rootNode;
99 | while (node.childCount > 0) {
100 | const foundNode = node.children.find((n) => rangeContainsPosition(getNodeRange(n), pos))!;
101 |
102 | if (!foundNode) return node;
103 |
104 | node = foundNode;
105 | }
106 |
107 | return node;
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/packages/language-server/src/signature-helps/SignatureHelpProvider.ts:
--------------------------------------------------------------------------------
1 | import { Connection, SignatureHelp, SignatureHelpParams, SignatureInformation } from 'vscode-languageserver';
2 | import type { SyntaxNode } from 'web-tree-sitter';
3 | import { twigFunctionsSignatureInformation } from './staticSignatureInformation';
4 | import { Document, DocumentCache } from '../documents';
5 | import { IFrameworkTwigEnvironment } from '../twigEnvironment';
6 | import { SignatureIndex } from './SignatureIndex';
7 |
8 | export class SignatureHelpProvider {
9 | #signatureIndex = new SignatureIndex(null);
10 |
11 | constructor(
12 | connection: Connection,
13 | private readonly documentCache: DocumentCache,
14 | ) {
15 | connection.onSignatureHelp(
16 | this.provideSignatureHelp.bind(this),
17 | );
18 | }
19 |
20 | reindex({ environment }: IFrameworkTwigEnvironment) {
21 | this.#signatureIndex = new SignatureIndex(environment);
22 | }
23 |
24 | async provideSignatureHelp(
25 | params: SignatureHelpParams,
26 | ): Promise {
27 | const document = await this.documentCache.get(params.textDocument.uri);
28 |
29 | if (!document) {
30 | return undefined;
31 | }
32 |
33 | const cursorNode = document.deepestAt(params.position);
34 | if (!cursorNode) return;
35 |
36 | const argumentsNode = cursorNode.parent;
37 | if (argumentsNode?.type !== 'arguments') return;
38 |
39 | const callExpression = argumentsNode.parent;
40 | if (!callExpression || callExpression.type !== 'call_expression') return;
41 |
42 | const callName = callExpression.childForFieldName('name')?.text;
43 |
44 | if (!callName) return;
45 |
46 | const signatureInformation = await this.getSignatureInformation(document, callName);
47 | if (!signatureInformation?.parameters?.length) return;
48 |
49 | let activeParameter = 0;
50 |
51 | let node: SyntaxNode | null = argumentsNode.firstChild;
52 | while (node) {
53 | if (node.text === ',') {
54 | activeParameter++;
55 | }
56 |
57 | if (node.equals(cursorNode)) {
58 | break;
59 | }
60 |
61 | node = node.nextSibling;
62 | }
63 |
64 | return {
65 | signatures: [signatureInformation],
66 | activeParameter,
67 | } as SignatureHelp;
68 | }
69 |
70 | async getSignatureInformation(document: Document, functionName: string): Promise {
71 | const twigHardcodedSignature = twigFunctionsSignatureInformation.get(functionName);
72 | if (twigHardcodedSignature) return twigHardcodedSignature;
73 |
74 | const twigEnvironmentSignature = this.#signatureIndex.get(functionName);
75 | if (twigEnvironmentSignature) return twigEnvironmentSignature;
76 |
77 | if (functionName.includes('.')) {
78 | const [ importName, macroName ] = functionName.split('.');
79 |
80 | const importedDocument = await this.documentCache.resolveImport(document, importName);
81 | if (!importedDocument) return;
82 |
83 | const macro = importedDocument.locals.macro.find(macro => macro.name === macroName);
84 | if (!macro) return;
85 |
86 | const argsStr = macro.args
87 | .map(({ name, value }) => value ? `${name} = ${value}` : name)
88 | .join(', ');
89 |
90 | return {
91 | label: `${functionName}(${argsStr})`,
92 | parameters: macro.args.map(arg => ({ label: arg.name })),
93 | };
94 | }
95 |
96 | return undefined;
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/packages/language-server/src/typing/ExpressionTypeResolver.ts:
--------------------------------------------------------------------------------
1 | import { SyntaxNode } from 'web-tree-sitter';
2 | import { ReflectedType } from '../phpInterop/ReflectedType';
3 | import { ITypeResolver } from './ITypeResolver';
4 | import { LocalSymbolInformation, hasReflectedType } from '../symbols/types';
5 | import { IExpressionTypeResolver } from './IExpressionTypeResolver';
6 | import { primitives } from '../phpInterop/primitives';
7 |
8 | // TODO: provide Twig environment, resolve type for `{{ something() }}`
9 |
10 | export class ExpressionTypeResolver implements IExpressionTypeResolver {
11 | static supportedTypes = new Set([
12 | 'call_expression',
13 | 'member_expression',
14 | 'variable',
15 | 'var_declaration',
16 | // TODO: handle these
17 | // 'subscript_expression',
18 | // 'filter_expression',
19 | // 'parenthesized_expression',
20 | ]);
21 |
22 | constructor(
23 | private readonly typeResolver: ITypeResolver,
24 | ) {
25 | }
26 |
27 | async resolveExpression(expr: SyntaxNode, locals: LocalSymbolInformation): Promise {
28 | if (expr.type === 'call_expression') {
29 | const memberExpr = expr.childForFieldName('name');
30 | if (!memberExpr) return null;
31 |
32 | return await this.resolveExpression(memberExpr, locals);
33 | }
34 |
35 | if (expr.type === 'member_expression') {
36 | const objectNode = expr.childForFieldName('object');
37 | if (!objectNode) return null;
38 |
39 | const objectType = await this.resolveExpression(objectNode, locals);
40 | if (!objectType) return null;
41 |
42 | const propertyName = expr.childForFieldName('property')?.text;
43 | if (!propertyName) return null;
44 |
45 | const propertyOrMethod = [
46 | ...objectType.methods,
47 | ...objectType.properties,
48 | ].find(m => m.name === propertyName);
49 |
50 | if (!propertyOrMethod) return null;
51 |
52 | if (propertyOrMethod.type === 'self') {
53 | return objectType;
54 | }
55 |
56 | if (primitives.has(propertyOrMethod.type)) {
57 | return null;
58 | }
59 |
60 | return await this.typeResolver.reflectType(propertyOrMethod.type);
61 | }
62 |
63 | if (expr.type === 'variable') {
64 | const variable = locals.variableDefinition.get(expr.text);
65 | if (!variable || !hasReflectedType(variable) || !variable.type) return null;
66 |
67 | return variable.reflectedType;
68 | }
69 |
70 | if (expr.type === 'var_declaration') {
71 | const typeNode = expr.childForFieldName('type');
72 |
73 | if (!typeNode) return null;
74 |
75 | if (typeNode.type === 'array_type') {
76 | const arrayItemType = typeNode.firstChild?.text;
77 |
78 | // just in case
79 | if (!arrayItemType) return null;
80 |
81 | const itemReflectedType = await this.typeResolver.reflectType(arrayItemType);
82 |
83 | const reflectedType: ReflectedType = {
84 | properties: [],
85 | methods: [],
86 | arrayType: {
87 | itemType: arrayItemType,
88 | itemReflectedType: itemReflectedType,
89 | },
90 | };
91 |
92 | return reflectedType;
93 | }
94 |
95 | return this.typeResolver.reflectType(typeNode.text);
96 | }
97 |
98 | return null;
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/packages/vscode/src/autoInsert.ts:
--------------------------------------------------------------------------------
1 | import * as vscode from 'vscode';
2 | import type { BaseLanguageClient } from 'vscode-languageclient';
3 | import { AutoInsertRequest } from 'twiggy-language-server/src/customRequests/AutoInsertRequest';
4 |
5 | export async function activate(
6 | clients: BaseLanguageClient[],
7 | active: (document: vscode.TextDocument) => boolean,
8 | ) {
9 | let isEnabled = false;
10 | let timeout: NodeJS.Timeout | undefined;
11 |
12 | updateEnabledState();
13 |
14 | const d1 = vscode.workspace.onDidChangeTextDocument(onDidChangeTextDocument, null);
15 | const d2 = vscode.window.onDidChangeActiveTextEditor(updateEnabledState, null);
16 |
17 | return vscode.Disposable.from(d1, d2);
18 |
19 | function updateEnabledState() {
20 | isEnabled = false;
21 | const editor = vscode.window.activeTextEditor;
22 | if (!editor) {
23 | return;
24 | }
25 |
26 | const document = editor.document;
27 | if (!active(document)) {
28 | return;
29 | }
30 | isEnabled = true;
31 | }
32 |
33 | function onDidChangeTextDocument({ document, contentChanges, reason }: vscode.TextDocumentChangeEvent) {
34 | if (!isEnabled || contentChanges.length === 0 || reason === vscode.TextDocumentChangeReason.Undo || reason === vscode.TextDocumentChangeReason.Redo) {
35 | return;
36 | }
37 | const activeDocument = vscode.window.activeTextEditor?.document;
38 | if (document !== activeDocument) {
39 | return;
40 | }
41 |
42 | const lastChange = contentChanges[contentChanges.length - 1];
43 |
44 | doAutoInsert(document, lastChange, async (document, position, lastChange, isCancel) => {
45 | for (const client of clients) {
46 | const params = {
47 | ...client.code2ProtocolConverter.asTextDocumentPositionParams(document, position),
48 | options: {
49 | lastChange: {
50 | ...lastChange,
51 | range: client.code2ProtocolConverter.asRange(lastChange.range),
52 | },
53 | },
54 | };
55 |
56 | if (isCancel()) return;
57 |
58 | const result = await client.sendRequest(AutoInsertRequest.type, params);
59 |
60 | if (result === undefined || result === null) {
61 | continue;
62 | }
63 |
64 | return typeof result === 'string'
65 | ? result
66 | : client.protocol2CodeConverter.asTextEdit(result);
67 | }
68 | });
69 | }
70 |
71 | function doAutoInsert(
72 | document: vscode.TextDocument,
73 | lastChange: vscode.TextDocumentContentChangeEvent,
74 | provider: (document: vscode.TextDocument, position: vscode.Position, lastChange: vscode.TextDocumentContentChangeEvent, isCancel: () => boolean) => Thenable,
75 | ) {
76 | if (timeout) {
77 | clearTimeout(timeout);
78 | timeout = undefined;
79 | }
80 | const version = document.version;
81 | timeout = setTimeout(() => {
82 | const rangeStart = lastChange.range.start;
83 | const position = new vscode.Position(rangeStart.line, rangeStart.character + lastChange.text.length);
84 | provider(document, position, lastChange, () => vscode.window.activeTextEditor?.document.version !== version).then(text => {
85 | if (!(text && isEnabled)) return;
86 |
87 | const activeEditor = vscode.window.activeTextEditor;
88 | if (!activeEditor) return;
89 |
90 | const activeDocument = activeEditor.document;
91 | if (document !== activeDocument || activeDocument.version !== version) return;
92 |
93 | if (typeof text === 'string') {
94 | const selections = activeEditor.selections;
95 | if (selections.length && selections.some(s => s.active.isEqual(position))) {
96 | activeEditor.insertSnippet(new vscode.SnippetString(text), selections.map(s => s.active));
97 | }
98 | else {
99 | activeEditor.insertSnippet(new vscode.SnippetString(text), position);
100 | }
101 | }
102 | else {
103 | activeEditor.insertSnippet(new vscode.SnippetString(text.newText), text.range);
104 | }
105 | });
106 | timeout = undefined;
107 | }, 100);
108 | }
109 | }
110 |
--------------------------------------------------------------------------------
/packages/language-server/src/documents/DocumentCache.ts:
--------------------------------------------------------------------------------
1 | import { DocumentUri, Position, WorkspaceFolder } from 'vscode-languageserver';
2 | import { documentUriToFsPath, toDocumentUri } from '../utils/uri';
3 | import { Document } from './Document';
4 | import * as path from 'path';
5 | import { resolveTemplate } from '../utils/files/resolveTemplate';
6 | import { EmptyEnvironment, IFrameworkTwigEnvironment } from '../twigEnvironment/IFrameworkTwigEnvironment';
7 | import { parser } from '../utils/parser';
8 | import { LocalSymbolCollector } from '../symbols/LocalSymbolCollector';
9 | import { ITypeResolver } from '../typing/ITypeResolver';
10 | import { readFile } from 'fs/promises';
11 |
12 | export class DocumentCache {
13 | #environment: IFrameworkTwigEnvironment = EmptyEnvironment;
14 | #typeResolver: ITypeResolver | null = null;
15 |
16 | readonly documents: Map = new Map();
17 | readonly workspaceFolderPath: string;
18 |
19 | constructor(workspaceFolder: WorkspaceFolder) {
20 | this.workspaceFolderPath = documentUriToFsPath(workspaceFolder.uri);
21 | }
22 |
23 | configure(frameworkEnvironment: IFrameworkTwigEnvironment, typeResolver: ITypeResolver | null) {
24 | this.#environment = frameworkEnvironment;
25 | this.#typeResolver = typeResolver;
26 | }
27 |
28 | async get(documentUri: DocumentUri, text?: string) {
29 | const document = this.documents.get(documentUri);
30 |
31 | if (!document) {
32 | return await this.add(documentUri, text);
33 | }
34 |
35 | if (document.text === null || text !== undefined) {
36 | await this.setText(document, text);
37 | }
38 |
39 | return document;
40 | }
41 |
42 | async updateText(documentUri: DocumentUri, text?: string) {
43 | return await this.get(documentUri, text);
44 | }
45 |
46 | async setText(document: Document, text?: string) {
47 | if (typeof text === 'string') {
48 | document.text = text;
49 | } else {
50 | const fsPath = documentUriToFsPath(document.uri);
51 | const text = await readFile(fsPath, 'utf-8');
52 | document.text = text;
53 | }
54 |
55 | document.tree = parser.parse(document.text);
56 | document.locals = await new LocalSymbolCollector(document.tree.rootNode, this.#typeResolver).collect();
57 | }
58 |
59 | async resolveByTwigPath(pathFromTwig: string) {
60 | for (const { namespace, directory } of this.#environment.templateMappings) {
61 | if (!pathFromTwig.startsWith(namespace)) {
62 | continue;
63 | }
64 |
65 | const includePath = namespace === ''
66 | ? path.join(directory, pathFromTwig)
67 | : pathFromTwig.replace(namespace, directory);
68 |
69 | const pathToTwig = path.resolve(this.workspaceFolderPath, includePath);
70 | const documentUri = toDocumentUri(pathToTwig);
71 |
72 | if (this.documents.has(documentUri)) {
73 | return this.documents.get(documentUri)!;
74 | }
75 |
76 | const resolvedTemplate = await resolveTemplate(pathToTwig);
77 | if (resolvedTemplate) {
78 | const newDocument = await this.add(toDocumentUri(resolvedTemplate));
79 | return newDocument;
80 | }
81 | }
82 |
83 | return undefined;
84 | }
85 |
86 | async resolveImport(document: Document, variableName: string, pos?: Position) {
87 | if (variableName === '_self') return document;
88 |
89 | const imports = document.locals.imports;
90 | if (pos !== undefined) {
91 | const scopedImports = document.getScopeAt(pos)?.imports;
92 | if (scopedImports) {
93 | imports.push(...scopedImports);
94 | }
95 | }
96 |
97 | const twigImport = imports?.find(imp => imp.name === variableName);
98 |
99 | if (!twigImport) return;
100 |
101 | if (!twigImport.path) return document;
102 |
103 | return await this.resolveByTwigPath(twigImport.path)!;
104 | }
105 |
106 | private async add(documentUri: DocumentUri, text?: string) {
107 | documentUri = toDocumentUri(documentUri);
108 |
109 | const document = new Document(documentUri);
110 | await this.setText(document, text);
111 | this.documents.set(documentUri, document);
112 | return document;
113 | }
114 | }
115 |
--------------------------------------------------------------------------------
/packages/language-server/phpUtils/getTwigMetadata.php:
--------------------------------------------------------------------------------
1 | 3 && ctype_alpha($file[0])
12 | && ':' === $file[1]
13 | && strspn($file, '/\\', 2, 1)
14 | )
15 | || null !== parse_url($file, \PHP_URL_SCHEME)
16 | ;
17 | }
18 |
19 | /**
20 | * Map supported loader namespaces to paths.
21 | * @param array &$loaderPaths
22 | * @param LoaderInterface $loader Loader.
23 | */
24 | function mapNamespaces(array &$loaderPaths, LoaderInterface $loader): void {
25 | if ($loader instanceof \Twig\Loader\ChainLoader) {
26 | foreach ($loader->getLoaders() as $subLoader) {
27 | mapNamespaces($loaderPaths, $subLoader);
28 | }
29 |
30 | return;
31 | }
32 |
33 | if ($loader instanceof \Twig\Loader\FilesystemLoader) {
34 | $namespaces = $loader->getNamespaces();
35 | $rootPath = getcwd() . \DIRECTORY_SEPARATOR;
36 |
37 | foreach ($namespaces as $namespace) {
38 | $ns_index = \Twig\Loader\FilesystemLoader::MAIN_NAMESPACE === $namespace
39 | ? ''
40 | : ('@' . $namespace);
41 |
42 | $loaderPaths[$ns_index] = [];
43 | foreach ($loader->getPaths($namespace) as $path) {
44 | $loaderPaths[$ns_index][] = realpath(
45 | isAbsolutePath($path)
46 | ? $path
47 | : $rootPath . $path
48 | );
49 | }
50 | }
51 | }
52 | }
53 |
54 | function getTwigMetadata(\Twig\Environment $twig, string $framework = ''): array {
55 | $loaderPathsArray = [];
56 | if ($framework !== 'craft') {
57 | mapNamespaces($loaderPathsArray, $twig->getLoader());
58 | }
59 |
60 | $globals = $twig->getGlobals();
61 | $globalsArray = [];
62 | $emptyArrayObject = new \ArrayObject();
63 | foreach ($globals as $key => $value) {
64 | $globalsArray[$key] = is_scalar($value) ? $value : $emptyArrayObject;
65 | }
66 |
67 | return [
68 | 'functions' => array_reduce(
69 | $twig->getFunctions(),
70 | fn ($acc, $item) => $acc + [$item->getName() => \Twiggy\Metadata\getArguments('functions', $item)],
71 | [],
72 | ),
73 | 'filters' => array_reduce(
74 | $twig->getFilters(),
75 | fn ($acc, $item) => $acc + [$item->getName() => \Twiggy\Metadata\getArguments('filters', $item)],
76 | [],
77 | ),
78 | 'tests' => array_values(
79 | array_map(
80 | fn ($item) => $item->getName(),
81 | $twig->getTests(),
82 | ),
83 | ),
84 | 'globals' => $globalsArray,
85 | 'loader_paths' => $loaderPathsArray,
86 | ];
87 | }
88 |
89 | // https://github.com/symfony/twig-bridge/blob/1d5745dac2e043553177a3b88a76b99c2a2f6c2e/Command/DebugCommand.php#L305-L361
90 | function getArguments(string $type, \Twig\TwigFunction|\Twig\TwigFilter $entity): mixed {
91 | $cb = $entity->getCallable();
92 | if (null === $cb) {
93 | return null;
94 | }
95 | if (\is_array($cb)) {
96 | if (!method_exists($cb[0], $cb[1])) {
97 | return null;
98 | }
99 | $refl = new \ReflectionMethod($cb[0], $cb[1]);
100 | } elseif (\is_object($cb) && method_exists($cb, '__invoke')) {
101 | $refl = new \ReflectionMethod($cb, '__invoke');
102 | } elseif (\function_exists($cb)) {
103 | $refl = new \ReflectionFunction($cb);
104 | } elseif (\is_string($cb) && preg_match('{^(.+)::(.+)$}', $cb, $m) && method_exists($m[1], $m[2])) {
105 | $refl = new \ReflectionMethod($m[1], $m[2]);
106 | } else {
107 | throw new \UnexpectedValueException('Unsupported callback type.');
108 | }
109 |
110 | $args = $refl->getParameters();
111 |
112 | // filter out context/environment args
113 | if ($entity->needsEnvironment()) {
114 | array_shift($args);
115 | }
116 | if ($entity->needsContext()) {
117 | array_shift($args);
118 | }
119 |
120 | if ('filters' === $type) {
121 | // remove the value the filter is applied on
122 | array_shift($args);
123 | }
124 |
125 | // format args
126 | $args = array_map(function (\ReflectionParameter $param) {
127 | if ($param->isDefaultValueAvailable()) {
128 | return $param->getName().' = '.json_encode($param->getDefaultValue());
129 | }
130 |
131 | return $param->getName();
132 | }, $args);
133 |
134 | return $args;
135 | }
136 |
--------------------------------------------------------------------------------
/packages/language-server/src/completions/variableProperties.ts:
--------------------------------------------------------------------------------
1 | import { CompletionItem, CompletionItemKind, InsertTextFormat } from 'vscode-languageserver/node';
2 | import { SyntaxNode } from 'web-tree-sitter';
3 | import { forLoopProperties } from '../staticCompletionInfo';
4 | import { Document, DocumentCache } from '../documents';
5 | import { triggerParameterHints } from '../signature-helps/triggerParameterHintsCommand';
6 | import { TwigMacro } from '../symbols/types';
7 | import { pointToPosition } from '../utils/position';
8 | import { Position } from 'vscode-languageserver-textdocument';
9 | import { ReflectedType } from '../phpInterop/ReflectedType';
10 | import { IExpressionTypeResolver } from '../typing/IExpressionTypeResolver';
11 | import { ExpressionTypeResolver } from '../typing/ExpressionTypeResolver';
12 |
13 | const macroToCompletionItem = (macro: TwigMacro) => ({
14 | label: macro.name,
15 | kind: CompletionItemKind.Function,
16 | insertTextFormat: InsertTextFormat.Snippet,
17 | command: triggerParameterHints,
18 | insertText: !macro.args.length
19 | ? `${macro.name}()$0`
20 | : macro.name,
21 | });
22 |
23 | const getVariableNode = (cursorNode: SyntaxNode) => {
24 | if (
25 | cursorNode.text === '.'
26 | && cursorNode.previousSibling?.type === 'variable'
27 | ) {
28 | return cursorNode.previousSibling;
29 | }
30 |
31 | if (
32 | cursorNode.parent?.type === 'subscript_expression'
33 | && cursorNode.type === 'string'
34 | ) {
35 | return cursorNode.parent.childForFieldName('object')!;
36 | }
37 |
38 | return null;
39 | };
40 |
41 | const getExpressionNode = (cursorNode: SyntaxNode) => {
42 | if (
43 | cursorNode.text === '.'
44 | && cursorNode.parent?.firstNamedChild
45 | && ExpressionTypeResolver.supportedTypes.has(cursorNode.parent.firstNamedChild.type)
46 | ) {
47 | return cursorNode.previousSibling;
48 | }
49 |
50 | return null;
51 | };
52 |
53 | const reflectedTypeToCompletions = (reflectedType: ReflectedType, options: { includeMethods: boolean }) => {
54 | const properties = reflectedType.properties.map((prop) => (({
55 | label: prop.name,
56 | detail: prop.type,
57 | kind: CompletionItemKind.Property
58 | }) as CompletionItem));
59 |
60 | if (!options.includeMethods) return properties;
61 |
62 | return [
63 | ...properties,
64 | ...reflectedType.methods.map((method) => (({
65 | label: method.name,
66 | kind: CompletionItemKind.Method,
67 | command: triggerParameterHints,
68 | insertTextFormat: InsertTextFormat.Snippet,
69 | detail: method.type,
70 | insertText: !method.parameters.length
71 | ? `${method.name}()$0`
72 | : `${method.name}($1)$0`,
73 | }) as CompletionItem)),
74 | ];
75 | }
76 |
77 | export async function variableProperties(
78 | document: Document,
79 | documentCache: DocumentCache,
80 | cursorNode: SyntaxNode,
81 | exprTypeResolver: IExpressionTypeResolver | null,
82 | pos: Position,
83 | ): Promise {
84 | const variableNode = getVariableNode(cursorNode);
85 | if (!variableNode) {
86 | if (!exprTypeResolver) return [];
87 |
88 | const expressionNode = getExpressionNode(cursorNode);
89 | if (!expressionNode) return [];
90 |
91 | const type = await exprTypeResolver.resolveExpression(expressionNode, document.locals);
92 | if (!type) return [];
93 |
94 | return reflectedTypeToCompletions(type, { includeMethods: true });
95 | }
96 |
97 | const variableName = variableNode.text;
98 |
99 | if (variableName === 'loop') {
100 | return forLoopProperties;
101 | }
102 |
103 | const variable = document.locals.variableDefinition.get(variableName);
104 | if (variable && 'reflectedType' in variable) {
105 | if (!exprTypeResolver || !variable.reflectedType) return [];
106 |
107 | return reflectedTypeToCompletions(variable.reflectedType, {
108 | includeMethods: cursorNode.parent!.type !== 'subscript_expression',
109 | });
110 | }
111 |
112 | const importedDocument = await documentCache.resolveImport(document, variableName, pos);
113 | if (importedDocument) {
114 | const localMacros = importedDocument.locals.macro;
115 |
116 | if (importedDocument !== document) {
117 | return localMacros.map(macroToCompletionItem);
118 | }
119 |
120 | const scopedMacros = importedDocument
121 | .getScopeAt(pointToPosition(cursorNode.startPosition))
122 | ?.macro || [];
123 |
124 | const allMacros = new Set([
125 | ...localMacros,
126 | ...scopedMacros,
127 | ]);
128 |
129 | return [...allMacros].map(macroToCompletionItem);
130 | }
131 |
132 | return [];
133 | }
134 |
--------------------------------------------------------------------------------
/packages/vscode/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "twiggy",
3 | "displayName": "Twiggy",
4 | "description": "Twig language support for VS Code",
5 | "author": "Mikhail Gunin ",
6 | "license": "Mozilla Public License 2.0",
7 | "version": "0.19.1",
8 | "engines": {
9 | "vscode": "^1.88.0"
10 | },
11 | "activationEvents": [
12 | "onLanguage:twig"
13 | ],
14 | "main": "./dist/extension.js",
15 | "publisher": "moetelo",
16 | "repository": {
17 | "type": "git",
18 | "url": "git+https://github.com/moetelo/twiggy.git",
19 | "directory": "packages/vscode"
20 | },
21 | "keywords": [
22 | "Twig"
23 | ],
24 | "categories": [
25 | "Programming Languages",
26 | "Linters"
27 | ],
28 | "icon": "assets/logo.png",
29 | "scripts": {
30 | "vscode:prepublish": "",
31 | "build": "node ./build/index.mjs",
32 | "dev": "node ./build/index.mjs --dev"
33 | },
34 | "contributes": {
35 | "configuration": {
36 | "title": "Twiggy",
37 | "properties": {
38 | "twiggy.autoInsertSpaces": {
39 | "type": "boolean",
40 | "default": true,
41 | "markdownDescription": "Insert spaces inside of `{{ | }}` and `{% | %}`."
42 | },
43 | "twiggy.diagnostics.twigCsFixer": {
44 | "type": "boolean",
45 | "default": false,
46 | "markdownDescription": "Use VincentLanglet/Twig-CS-Fixer, if installed. Note: format on save should be disabled since it's buggy."
47 | },
48 | "twiggy.inlayHints.macroArguments": {
49 | "type": "boolean",
50 | "default": true,
51 | "markdownDescription": "Inlay hints for macro arguments."
52 | },
53 | "twiggy.inlayHints.macro": {
54 | "type": "boolean",
55 | "default": true,
56 | "markdownDescription": "Inlay hints for `{% endmacro %}`."
57 | },
58 | "twiggy.inlayHints.block": {
59 | "type": "boolean",
60 | "default": true,
61 | "markdownDescription": "Inlay hints for `{% endblock %}`."
62 | },
63 | "twiggy.phpExecutable": {
64 | "type": "string",
65 | "scope": "resource",
66 | "default": "php",
67 | "markdownDescription": "Points to the PHP executable."
68 | },
69 | "twiggy.framework": {
70 | "type": "string",
71 | "scope": "resource",
72 | "enum": [
73 | "symfony",
74 | "craft",
75 | "twig",
76 | "ignore"
77 | ],
78 | "markdownDescription": "Framework to use."
79 | },
80 | "twiggy.vanillaTwigEnvironmentPath": {
81 | "type": "string",
82 | "scope": "resource",
83 | "default": "",
84 | "markdownDescription": "Path to the Twig environment file. To be used with `\"twiggy.framework\": \"twig\"`.\nSee: https://github.com/moetelo/twiggy/issues/52"
85 | },
86 | "twiggy.symfonyConsolePath": {
87 | "type": "string",
88 | "scope": "resource",
89 | "default": "bin/console",
90 | "markdownDescription": "Path to the Symfony console. See: https://symfony.com/doc/current/templates.html#inspecting-twig-information"
91 | }
92 | }
93 | },
94 | "languages": [
95 | {
96 | "id": "twig",
97 | "aliases": [
98 | "HTML (Twig)",
99 | "twig"
100 | ],
101 | "extensions": [
102 | ".twig",
103 | ".html.twig"
104 | ],
105 | "configuration": "./languages/twig.configuration.json"
106 | }
107 | ],
108 | "semanticTokenTypes": [
109 | {
110 | "id": "embedded_begin",
111 | "superType": "embedded_delimiter",
112 | "description": "Begin of embedded"
113 | },
114 | {
115 | "id": "embedded_end",
116 | "superType": "embedded_delimiter",
117 | "description": "End of embedded"
118 | },
119 | {
120 | "id": "null",
121 | "superType": "constant",
122 | "description": "null or none"
123 | },
124 | {
125 | "id": "boolean",
126 | "superType": "constant",
127 | "description": "true or false"
128 | }
129 | ],
130 | "configurationDefaults": {
131 | "editor.semanticTokenColorCustomizations": {
132 | "enabled": true,
133 | "rules": {
134 | "embedded_delimiter": {
135 | "foreground": "#777777"
136 | }
137 | }
138 | }
139 | },
140 | "grammars": [
141 | {
142 | "language": "twig",
143 | "scopeName": "text.html.twig",
144 | "path": "./syntaxes/twig.tmLanguage.json",
145 | "embeddedLanguages": {
146 | "source.twig": "twig",
147 | "source.js": "javascript",
148 | "source.json": "json",
149 | "source.css": "css"
150 | }
151 | }
152 | ]
153 | },
154 | "devDependencies": {
155 | "@types/node": "^20.12.7",
156 | "@types/vscode": "^1.88.0",
157 | "esbuild": "^0.20.2",
158 | "esbuild-plugin-copy": "^2.1.1",
159 | "typescript": "^5.4.5"
160 | },
161 | "dependencies": {
162 | "twiggy-language-server": "workspace:*",
163 | "vscode-languageclient": "^9.0.1"
164 | }
165 | }
166 |
--------------------------------------------------------------------------------
/packages/language-server/src/server.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Connection,
3 | InitializeParams,
4 | ServerCapabilities,
5 | TextDocuments,
6 | WorkspaceFolder,
7 | } from 'vscode-languageserver';
8 | import { TextDocument } from 'vscode-languageserver-textdocument';
9 | import { DiagnosticProvider } from './diagnostics';
10 | import { DocumentCache } from './documents';
11 | import { HoverProvider } from './hovers/HoverProvider';
12 | import { CompletionProvider } from './completions/CompletionProvider';
13 | import { SignatureHelpProvider } from './signature-helps/SignatureHelpProvider';
14 | import { semanticTokensLegend } from './semantic-tokens/tokens-provider';
15 | import { SemanticTokensProvider } from './semantic-tokens/SemanticTokensProvider';
16 | import { ConfigurationManager } from './configuration/ConfigurationManager';
17 | import { DefinitionProvider } from './definitions';
18 | import { SymbolProvider } from './symbols/SymbolProvider';
19 | import { initializeParser } from './utils/parser';
20 | import { IsInsideHtmlRegionCommandProvider } from './commands/IsInsideHtmlRegionCommandProvider';
21 | import { BracketSpacesInsertionProvider } from './autoInsertions/BracketSpacesInsertionProvider';
22 | import { InlayHintProvider } from './inlayHints/InlayHintProvider';
23 | import { ReferenceProvider } from './references/ReferenceProvider';
24 | import { RenameProvider } from './references/RenameProvider';
25 | import { FormattingProvider } from 'formatting/FormattingProvider';
26 |
27 | export class Server {
28 | readonly documents = new TextDocuments(TextDocument);
29 | documentCache!: DocumentCache;
30 | workspaceFolder!: WorkspaceFolder;
31 |
32 | definitionProvider!: DefinitionProvider;
33 | completionProvider!: CompletionProvider;
34 | bracketSpacesInsertionProvider!: BracketSpacesInsertionProvider;
35 | inlayHintProvider!: InlayHintProvider;
36 | signatureHelpProvider!: SignatureHelpProvider;
37 | referenceProvider!: ReferenceProvider;
38 | renameProvider!: RenameProvider;
39 | diagnosticProvider!: DiagnosticProvider;
40 | formattingProvider!: FormattingProvider;
41 |
42 | constructor(connection: Connection) {
43 | connection.onInitialize(async (initializeParams: InitializeParams) => {
44 | this.workspaceFolder = initializeParams.workspaceFolders![0];
45 |
46 | const documentCache = new DocumentCache(this.workspaceFolder);
47 | this.documentCache = documentCache;
48 |
49 | this.diagnosticProvider = new DiagnosticProvider(connection, documentCache);
50 | await initializeParser();
51 |
52 | new SemanticTokensProvider(connection, documentCache);
53 | new SymbolProvider(connection, documentCache);
54 | new HoverProvider(connection, documentCache);
55 | this.signatureHelpProvider = new SignatureHelpProvider(connection, documentCache);
56 | this.referenceProvider = new ReferenceProvider(connection, documentCache);
57 | this.renameProvider = new RenameProvider(connection, documentCache);
58 | this.definitionProvider = new DefinitionProvider(
59 | connection,
60 | documentCache,
61 | this.workspaceFolder,
62 | );
63 | this.completionProvider = new CompletionProvider(
64 | connection,
65 | documentCache,
66 | this.workspaceFolder,
67 | );
68 | this.inlayHintProvider = new InlayHintProvider(connection, documentCache);
69 | new IsInsideHtmlRegionCommandProvider(connection, documentCache);
70 | this.formattingProvider = new FormattingProvider(connection, this.diagnosticProvider);
71 | this.bracketSpacesInsertionProvider = new BracketSpacesInsertionProvider(
72 | connection,
73 | this.documents,
74 | );
75 |
76 | const capabilities: ServerCapabilities = {
77 | hoverProvider: true,
78 | definitionProvider: true,
79 | documentSymbolProvider: true,
80 | completionProvider: {
81 | resolveProvider: true,
82 | triggerCharacters: ['<', '"', "'", '|', '.', '{', '\\'],
83 | },
84 | signatureHelpProvider: {
85 | triggerCharacters: ['(', ','],
86 | },
87 | semanticTokensProvider: {
88 | legend: semanticTokensLegend,
89 | full: true,
90 | },
91 | inlayHintProvider: true,
92 | referencesProvider: true,
93 | documentFormattingProvider: true,
94 | renameProvider: {
95 | prepareProvider: true,
96 | },
97 | };
98 |
99 | return {
100 | capabilities,
101 | };
102 | });
103 |
104 | connection.onInitialized(async () => {
105 | new ConfigurationManager(
106 | connection,
107 | this.definitionProvider,
108 | this.inlayHintProvider,
109 | this.bracketSpacesInsertionProvider,
110 | this.completionProvider,
111 | this.signatureHelpProvider,
112 | this.documentCache,
113 | this.workspaceFolder,
114 | this.diagnosticProvider,
115 | this.formattingProvider,
116 | );
117 |
118 | await this.diagnosticProvider.lintWorkspace();
119 | });
120 |
121 | this.documents.onDidSave(async ({ document }) => {
122 | await this.diagnosticProvider.lint(document.uri);
123 | });
124 |
125 | this.documents.onDidChangeContent(async ({ document }) => {
126 | const doc = await this.documentCache.updateText(document.uri, document.getText());
127 | await this.diagnosticProvider.validateReport(doc);
128 | });
129 |
130 | this.documents.listen(connection);
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/packages/vscode/src/extension.ts:
--------------------------------------------------------------------------------
1 | import {
2 | workspace,
3 | ExtensionContext,
4 | window,
5 | WorkspaceFolder,
6 | RelativePattern,
7 | commands,
8 | CompletionList,
9 | Uri,
10 | CompletionItem,
11 | } from 'vscode';
12 | import * as autoInsert from './autoInsert';
13 | import {
14 | LanguageClient,
15 | LanguageClientOptions,
16 | MarkupKind,
17 | ServerOptions,
18 | TransportKind,
19 | } from 'vscode-languageclient/node';
20 | import { unwrapCompletionArray } from './utils/unwrapCompletionArray';
21 | import ProtocolCompletionItem from 'vscode-languageclient/lib/common/protocolCompletionItem';
22 | import { IsInsideHtmlRegionRequest } from 'twiggy-language-server/src/customRequests/IsInsideHtmlRegionRequest';
23 |
24 | const outputChannel = window.createOutputChannel('Twiggy Language Server');
25 | const clients = new Map();
26 |
27 | export async function activate(context: ExtensionContext) {
28 | workspace.workspaceFolders?.forEach((folder) =>
29 | addWorkspaceFolder(folder, context)
30 | );
31 |
32 | workspace.onDidChangeWorkspaceFolders(({ added, removed }) => {
33 | added.forEach((folder) => addWorkspaceFolder(folder, context));
34 | removed.forEach((folder) => removeWorkspaceFolder(folder));
35 | });
36 | }
37 |
38 | export async function deactivate(): Promise {
39 | for (const client of clients.values()) {
40 | await client.stop();
41 | }
42 | }
43 |
44 | async function addWorkspaceFolder(
45 | workspaceFolder: WorkspaceFolder,
46 | context: ExtensionContext
47 | ): Promise {
48 | const folderPath = workspaceFolder.uri.fsPath;
49 | const fileEvents = workspace.createFileSystemWatcher(
50 | new RelativePattern(workspaceFolder, '*.twig'),
51 | );
52 |
53 | context.subscriptions.push(fileEvents);
54 |
55 | if (clients.has(folderPath)) {
56 | return;
57 | }
58 |
59 | const module = require.resolve('./server');
60 |
61 | const serverOptions: ServerOptions = {
62 | run: { module, transport: TransportKind.ipc },
63 | debug: {
64 | module,
65 | transport: TransportKind.ipc,
66 | options: { execArgv: ['--nolazy', `--inspect=6009`] },
67 | },
68 | };
69 |
70 | const virtualDocumentContents = new Map();
71 |
72 | workspace.registerTextDocumentContentProvider('embedded-content', {
73 | provideTextDocumentContent(uri) {
74 | const originalUri = uri.path.slice('/'.length, -'.html'.length);
75 | const decodedUri = decodeURIComponent(originalUri);
76 | return virtualDocumentContents.get(decodedUri);
77 | },
78 | });
79 |
80 | const workspaceUri = workspaceFolder.uri.toString(true);
81 |
82 | const clientOptions: LanguageClientOptions = {
83 | workspaceFolder,
84 | outputChannel,
85 | documentSelector: [
86 | {
87 | scheme: 'file',
88 | language: 'twig',
89 | pattern: `${folderPath}/**`,
90 | },
91 | ],
92 | synchronize: {
93 | fileEvents,
94 | },
95 | middleware: {
96 | async provideCompletionItem(
97 | document,
98 | position,
99 | context,
100 | token,
101 | next,
102 | ) {
103 | const originalUri = document.uri.toString(true);
104 |
105 | const isInsideHtmlRegion = await client.sendRequest(IsInsideHtmlRegionRequest.type, {
106 | textDocument: { uri: originalUri },
107 | position,
108 | } as IsInsideHtmlRegionRequest.ParamsType);
109 |
110 | const result = await unwrapCompletionArray(next(document, position, context, token)) as ProtocolCompletionItem[];
111 |
112 | if (!isInsideHtmlRegion) {
113 | return result;
114 | }
115 |
116 | virtualDocumentContents.set(originalUri, document.getText());
117 |
118 | const encodedUri = encodeURIComponent(originalUri);
119 | const vdocUri = Uri.parse(`embedded-content://html/${encodedUri}.html`);
120 | const htmlCompletionList = await commands.executeCommand>(
121 | 'vscode.executeCompletionItemProvider',
122 | vdocUri,
123 | position,
124 | context.triggerCharacter,
125 | );
126 |
127 | const htmlItems = htmlCompletionList
128 | .items
129 | .map(item => {
130 | const protoItem = Object.assign(
131 | new ProtocolCompletionItem(item.label),
132 | item,
133 | );
134 |
135 | protoItem.documentationFormat = MarkupKind.Markdown;
136 |
137 | return protoItem;
138 | });
139 |
140 | return [
141 | ...htmlItems,
142 | ...result,
143 | ];
144 | },
145 | },
146 | };
147 |
148 | const client = new LanguageClient(
149 | 'twiggy-language-server ' + workspaceUri,
150 | 'Twiggy Language Server ' + workspaceUri,
151 | serverOptions,
152 | clientOptions,
153 | );
154 |
155 | await autoInsert.activate(
156 | [client],
157 | (document) => document.uri.fsPath.startsWith(folderPath),
158 | );
159 |
160 | clients.set(folderPath, client);
161 |
162 | await client.start();
163 |
164 | outputChannel.appendLine('Language server started for: ' + folderPath);
165 | }
166 |
167 | async function removeWorkspaceFolder(
168 | workspaceFolder: WorkspaceFolder,
169 | ): Promise {
170 | const folderPath = workspaceFolder.uri.fsPath;
171 | const client = clients.get(folderPath);
172 |
173 | if (client) {
174 | clients.delete(folderPath);
175 |
176 | await client.stop();
177 | }
178 | }
179 |
--------------------------------------------------------------------------------
/packages/language-server/src/definitions/index.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Connection,
3 | Definition,
4 | DefinitionParams,
5 | Range,
6 | WorkspaceFolder,
7 | } from 'vscode-languageserver';
8 | import { getNodeRange, isBlockIdentifier, isPathInsideTemplateEmbedding } from '../utils/node';
9 | import { Document, DocumentCache } from '../documents';
10 | import { getStringNodeValue } from '../utils/node';
11 | import { pointToPosition } from '../utils/position';
12 | import { positionsEqual } from '../utils/position/comparePositions';
13 | import { documentUriToFsPath } from '../utils/uri';
14 | import { PhpExecutor } from '../phpInterop/PhpExecutor';
15 | import { findParentByType } from '../utils/node/findParentByType';
16 | import { SyntaxNode } from 'web-tree-sitter';
17 |
18 | export class DefinitionProvider {
19 | workspaceFolderPath: string;
20 | phpExecutor: PhpExecutor | null = null;
21 |
22 | constructor(
23 | private readonly connection: Connection,
24 | private readonly documentCache: DocumentCache,
25 | workspaceFolder: WorkspaceFolder,
26 | ) {
27 | this.workspaceFolderPath = documentUriToFsPath(workspaceFolder.uri);
28 | this.connection.onDefinition(this.onDefinition.bind(this));
29 | }
30 |
31 | async onDefinition(
32 | params: DefinitionParams,
33 | ): Promise {
34 | const document = await this.documentCache.get(params.textDocument.uri);
35 |
36 | if (!document) {
37 | return;
38 | }
39 |
40 | const cursorNode = document.deepestAt(params.position);
41 | if (!cursorNode) {
42 | return;
43 | }
44 |
45 | if (isPathInsideTemplateEmbedding(cursorNode)) {
46 | const document = await this.documentCache.resolveByTwigPath(
47 | getStringNodeValue(cursorNode),
48 | );
49 |
50 | if (!document) return;
51 |
52 | return {
53 | uri: document.uri,
54 | range: Range.create(0, 0, 0, 0),
55 | };
56 | }
57 |
58 | if (isBlockIdentifier(cursorNode)) {
59 | if (!cursorNode.parent) {
60 | return;
61 | }
62 |
63 | if (cursorNode.parent.type === 'block') {
64 | const blockName = cursorNode.type === 'string'
65 | ? getStringNodeValue(cursorNode)
66 | : cursorNode.text;
67 |
68 | return await this.#resolveBlockSymbol(blockName, document, cursorNode);
69 | }
70 |
71 | if (cursorNode.parent.type === 'arguments') {
72 | const [blockNameArgNode, templatePathArgNode] = cursorNode.parent.namedChildren;
73 |
74 | const blockName = blockNameArgNode.type === 'string'
75 | ? getStringNodeValue(blockNameArgNode)
76 | : blockNameArgNode.text;
77 |
78 | if (!templatePathArgNode) {
79 | return await this.#resolveBlockSymbol(blockName, document, cursorNode);
80 | }
81 |
82 | const path = getStringNodeValue(templatePathArgNode);
83 | const resolvedDocument = await this.documentCache.resolveByTwigPath(path);
84 |
85 | if (!resolvedDocument) {
86 | // target template not found
87 | return;
88 | }
89 |
90 | if (!cursorNode.equals(templatePathArgNode)) {
91 | return await this.#resolveBlockSymbol(blockName, resolvedDocument, cursorNode);
92 | }
93 |
94 | return {
95 | uri: resolvedDocument.uri,
96 | range: Range.create(0, 0, 0, 0),
97 | };
98 | }
99 |
100 | return;
101 | }
102 |
103 | if (cursorNode.type === 'variable') {
104 | const cursorPosition = pointToPosition(cursorNode.startPosition);
105 | const scopedVariables = document.getLocalsAt(cursorPosition);
106 |
107 | const symbol = scopedVariables.find((x) => x.name === cursorNode.text);
108 |
109 | if (!symbol) return;
110 |
111 | return {
112 | uri: document.uri,
113 | range: symbol.nameRange,
114 | };
115 | }
116 |
117 | if (cursorNode.type === 'property') {
118 | const macroName = cursorNode.text;
119 | const importName = cursorNode.parent!.firstChild!.text;
120 |
121 | const importedDocument = await this.documentCache.resolveImport(document, importName, params.position);
122 | if (!importedDocument) return;
123 |
124 | const macro = importedDocument.locals.macro.find(macro => macro.name === macroName);
125 | if (!macro) return;
126 |
127 | return {
128 | uri: importedDocument.uri,
129 | range: macro.nameRange,
130 | };
131 | }
132 |
133 | const typeIdentifierNode = findParentByType(cursorNode, 'qualified_name');
134 | if (typeIdentifierNode) {
135 | if (!this.phpExecutor) return;
136 |
137 | const result = await this.phpExecutor.getClassDefinition(typeIdentifierNode.text);
138 | if (!result?.path) return;
139 |
140 | return {
141 | uri: result.path,
142 | range: getNodeRange(typeIdentifierNode),
143 | };
144 | }
145 | }
146 |
147 | async #resolveBlockSymbol(blockName: string, initialDocument: Document, cursorNode: SyntaxNode) {
148 | let extendedDocument: Document | undefined = initialDocument;
149 | while (extendedDocument) {
150 | const blockSymbol = extendedDocument.getBlock(blockName);
151 | if (!blockSymbol || positionsEqual(blockSymbol.nameRange.start, getNodeRange(cursorNode).start)) {
152 | extendedDocument = await this.getExtendedTemplate(extendedDocument);
153 | continue;
154 | }
155 | return {
156 | uri: extendedDocument.uri,
157 | range: blockSymbol.nameRange,
158 | };
159 | }
160 | return undefined;
161 | }
162 |
163 | private async getExtendedTemplate(document: Document) {
164 | if (!document.locals.extends) {
165 | return undefined;
166 | }
167 |
168 | return await this.documentCache.resolveByTwigPath(document.locals.extends);
169 | }
170 | }
171 |
--------------------------------------------------------------------------------
/packages/language-server/src/configuration/ConfigurationManager.ts:
--------------------------------------------------------------------------------
1 | import {
2 | Connection,
3 | DidChangeConfigurationNotification,
4 | DidChangeConfigurationParams,
5 | WorkspaceFolder,
6 | } from 'vscode-languageserver';
7 | import { LanguageServerSettings, PhpFramework, PhpFrameworkOption } from './LanguageServerSettings';
8 | import { InlayHintProvider } from '../inlayHints/InlayHintProvider';
9 | import { DocumentCache } from '../documents';
10 | import { BracketSpacesInsertionProvider } from '../autoInsertions/BracketSpacesInsertionProvider';
11 | import { CompletionProvider } from '../completions/CompletionProvider';
12 | import { SignatureHelpProvider } from '../signature-helps/SignatureHelpProvider';
13 | import { DefinitionProvider } from '../definitions';
14 | import { documentUriToFsPath } from '../utils/uri';
15 | import { PhpExecutor } from '../phpInterop/PhpExecutor';
16 | import {
17 | IFrameworkTwigEnvironment,
18 | SymfonyTwigEnvironment,
19 | CraftTwigEnvironment,
20 | VanillaTwigEnvironment,
21 | EmptyEnvironment,
22 | } from '../twigEnvironment';
23 | import { TypeResolver } from '../typing/TypeResolver';
24 | import { isFile } from '../utils/files/fileStat';
25 | import { readFile } from 'fs/promises';
26 | import { DiagnosticProvider } from 'diagnostics';
27 | import { FormattingProvider } from 'formatting/FormattingProvider';
28 | import { TwigCodeStyleFixer } from 'phpInterop/TwigCodeStyleFixer';
29 |
30 | export class ConfigurationManager {
31 | readonly configurationSection = 'twiggy';
32 |
33 | readonly defaultSettings: LanguageServerSettings = {
34 | autoInsertSpaces: true,
35 | inlayHints: InlayHintProvider.defaultSettings,
36 | phpExecutable: 'php',
37 | symfonyConsolePath: './bin/console',
38 | vanillaTwigEnvironmentPath: '',
39 | framework: PhpFrameworkOption.Symfony,
40 | diagnostics: {
41 | twigCsFixer: true,
42 | },
43 | };
44 |
45 | constructor(
46 | connection: Connection,
47 | private readonly definitionProvider: DefinitionProvider,
48 | private readonly inlayHintProvider: InlayHintProvider,
49 | private readonly bracketSpacesInsertionProvider: BracketSpacesInsertionProvider,
50 | private readonly completionProvider: CompletionProvider,
51 | private readonly signatureHelpProvider: SignatureHelpProvider,
52 | private readonly documentCache: DocumentCache,
53 | private readonly workspaceFolder: WorkspaceFolder,
54 | private readonly diagnosticProvider: DiagnosticProvider,
55 | private readonly formattingProvider: FormattingProvider,
56 | ) {
57 | connection.client.register(DidChangeConfigurationNotification.type, { section: this.configurationSection });
58 | connection.onDidChangeConfiguration(this.onDidChangeConfiguration.bind(this));
59 | }
60 |
61 | async onDidChangeConfiguration({ settings }: DidChangeConfigurationParams) {
62 | const config: LanguageServerSettings = settings?.[this.configurationSection] || this.defaultSettings;
63 |
64 | this.inlayHintProvider.settings = config.inlayHints ?? InlayHintProvider.defaultSettings;
65 | this.bracketSpacesInsertionProvider.isEnabled = config.autoInsertSpaces ?? true;
66 |
67 | this.applySettings(EmptyEnvironment, null, null);
68 |
69 | if (config.framework === PhpFrameworkOption.Ignore) {
70 | return;
71 | }
72 |
73 | const workspaceDirectory = documentUriToFsPath(this.workspaceFolder.uri);
74 | if (!config.framework) {
75 | config.framework = await this.#tryGuessFramework(workspaceDirectory);
76 |
77 | if (!config.framework) {
78 | console.warn('`twiggy.framework` is required.');
79 | return;
80 | }
81 |
82 | console.info('Guessed `twiggy.framework`: ', config.framework);
83 | }
84 |
85 | const phpExecutor = new PhpExecutor(config.phpExecutable, workspaceDirectory);
86 | const twigCodeStyleFixer = config.diagnostics.twigCsFixer
87 | ? new TwigCodeStyleFixer(phpExecutor, workspaceDirectory)
88 | : null;
89 |
90 | const twigEnvironment = this.#resolveTwigEnvironment(config.framework, phpExecutor);
91 | await twigEnvironment.refresh({
92 | symfonyConsolePath: config.symfonyConsolePath,
93 | vanillaTwigEnvironmentPath: config.vanillaTwigEnvironmentPath,
94 | workspaceDirectory,
95 | });
96 |
97 | if (null === twigEnvironment.environment) {
98 | console.warn('Failed to load Twig environment.')
99 | } else {
100 | console.info('Successfully loaded Twig environment.')
101 | console.debug(twigEnvironment.environment)
102 | }
103 |
104 | this.applySettings(twigEnvironment, phpExecutor, twigCodeStyleFixer);
105 | }
106 |
107 | #resolveTwigEnvironment(framework: PhpFramework, phpExecutor: PhpExecutor) {
108 | switch (framework) {
109 | case PhpFrameworkOption.Symfony:
110 | return new SymfonyTwigEnvironment(phpExecutor);
111 | case PhpFrameworkOption.Craft:
112 | return new CraftTwigEnvironment(phpExecutor);
113 | case PhpFrameworkOption.Twig:
114 | return new VanillaTwigEnvironment(phpExecutor);
115 | }
116 | }
117 |
118 | async #tryGuessFramework(workspaceDirectory: string): Promise {
119 | const composerJsonPath = `${workspaceDirectory}/composer.json`;
120 | if (!await isFile(composerJsonPath)) {
121 | return undefined;
122 | }
123 |
124 | const composerJson = await readFile(composerJsonPath, 'utf-8').then(JSON.parse);
125 | if (composerJson.require['symfony/twig-bundle']) {
126 | return PhpFrameworkOption.Symfony;
127 | }
128 | if (composerJson.require['craftcms/cms']) {
129 | return PhpFrameworkOption.Craft;
130 | }
131 | }
132 |
133 | private applySettings(
134 | frameworkEnvironment: IFrameworkTwigEnvironment,
135 | phpExecutor: PhpExecutor | null,
136 | twigCodeStyleFixer: TwigCodeStyleFixer | null,
137 | ) {
138 | const typeResolver = phpExecutor ? new TypeResolver(phpExecutor) : null;
139 |
140 | this.definitionProvider.phpExecutor = phpExecutor;
141 | this.completionProvider.refresh(frameworkEnvironment, phpExecutor, typeResolver);
142 | this.signatureHelpProvider.reindex(frameworkEnvironment);
143 | this.documentCache.configure(frameworkEnvironment, typeResolver);
144 |
145 | this.diagnosticProvider.refresh(twigCodeStyleFixer);
146 | this.formattingProvider.refresh(twigCodeStyleFixer);
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/packages/language-server/src/diagnostics/DiagnosticProvider.ts:
--------------------------------------------------------------------------------
1 | import { Connection, Diagnostic, DiagnosticSeverity, DiagnosticTag, DocumentUri, Range } from 'vscode-languageserver';
2 | import { Document, DocumentCache } from 'documents';
3 | import { PreOrderCursorIterator, getNodeRange, getStringNodeValue, isBlockIdentifier, isEmptyEmbedded, isPathInsideTemplateEmbedding } from 'utils/node';
4 | import { SyntaxNode } from 'web-tree-sitter';
5 | import { pointToPosition } from 'utils/position';
6 | import { positionsEqual } from 'utils/position/comparePositions';
7 | import { TwigCodeStyleFixer } from 'phpInterop/TwigCodeStyleFixer';
8 |
9 | const createDiagnosticFromRange = (
10 | range: Range,
11 | message: string,
12 | severity: DiagnosticSeverity = DiagnosticSeverity.Warning,
13 | ): Diagnostic => ({
14 | severity,
15 | range,
16 | message,
17 | });
18 |
19 | const createDiagnostic = (
20 | node: SyntaxNode,
21 | message: string,
22 | severity: DiagnosticSeverity = DiagnosticSeverity.Warning,
23 | ): Diagnostic => createDiagnosticFromRange(
24 | getNodeRange(node),
25 | message,
26 | severity,
27 | );
28 |
29 |
30 | export class DiagnosticProvider {
31 | #twigCodeStyleFixer: TwigCodeStyleFixer | null = null;
32 |
33 | #lintMap = new Map();
34 |
35 | constructor(
36 | private readonly connection: Connection,
37 | private readonly documentCache: DocumentCache,
38 | ) {
39 | }
40 |
41 | refresh(twigCodeStyleFixer: TwigCodeStyleFixer | null) {
42 | this.#twigCodeStyleFixer = twigCodeStyleFixer;
43 | this.#lintMap.clear();
44 |
45 | void this.lintWorkspace();
46 | }
47 |
48 | async validateNode(node: SyntaxNode, document: Document): Promise {
49 | if (node.type === 'ERROR') {
50 | return createDiagnostic(node, 'Unexpected syntax');
51 | }
52 |
53 | if (node.type === 'if' && !node.childForFieldName('expr')) {
54 | const diag = createDiagnostic(node, 'Empty if condition');
55 |
56 | // {% if %}
57 | // ^
58 | const ifEmbeddedEnd = node.descendantsOfType('embedded_end')[0];
59 | diag.range.end = pointToPosition(ifEmbeddedEnd.endPosition);
60 | return diag;
61 | }
62 |
63 | if (isEmptyEmbedded(node)) {
64 | return createDiagnostic(node, `Empty ${node.type} block`);
65 | }
66 |
67 |
68 | if (isPathInsideTemplateEmbedding(node)) {
69 | const path = getStringNodeValue(node);
70 | const document = await this.documentCache.resolveByTwigPath(path);
71 |
72 | if (!document) {
73 | return createDiagnostic(node, `Template "${path}" not found`, DiagnosticSeverity.Error)
74 | }
75 |
76 | // found template, no errors
77 | return null;
78 | }
79 |
80 | if (isBlockIdentifier(node)) {
81 | if (node.parent?.type !== 'block') {
82 | let extendedDocument: Document | undefined = document;
83 | let documentName = "this document";
84 |
85 | const blockArgumentNode = node.parent!.namedChildren[0];
86 | const templateArgumentNode = node.parent!.namedChildren[1];
87 |
88 | if (templateArgumentNode) {
89 | const path = getStringNodeValue(templateArgumentNode);
90 | const document = await this.documentCache.resolveByTwigPath(path);
91 |
92 | if (node.equals(templateArgumentNode)) {
93 | if (!document) {
94 | return createDiagnostic(templateArgumentNode, `Template "${path}" not found`, DiagnosticSeverity.Error)
95 | }
96 | // found template, no errors
97 | // block existence will be checked in next pass
98 | return null;
99 | }
100 |
101 | extendedDocument = document;
102 | documentName = path;
103 | }
104 |
105 | const blockName = blockArgumentNode.type === 'string'
106 | ? getStringNodeValue(blockArgumentNode)
107 | : blockArgumentNode.text;
108 |
109 | let found = false;
110 | while (extendedDocument) {
111 | const blockSymbol = extendedDocument.getBlock(blockName);
112 | if (!blockSymbol || positionsEqual(blockSymbol.nameRange.start, getNodeRange(blockArgumentNode).start)) {
113 | if (!extendedDocument.locals.extends) {
114 | extendedDocument = void 0;
115 | } else {
116 | extendedDocument = await this.documentCache.resolveByTwigPath(extendedDocument.locals.extends);
117 | }
118 | continue;
119 | }
120 | found = true;
121 | break;
122 | }
123 | if (!found) {
124 | return createDiagnostic(blockArgumentNode, `Block "${blockName}" not found in ${documentName}`, DiagnosticSeverity.Error)
125 | }
126 | }
127 | }
128 |
129 | return null;
130 | }
131 |
132 | async validateTree(document: Document) {
133 | const { tree } = document;
134 |
135 | const diagnostics: Diagnostic[] = [];
136 |
137 | const cursor = tree.walk();
138 |
139 | for (const node of new PreOrderCursorIterator(cursor)) {
140 | const diagnostic = await this.validateNode(node.currentNode, document);
141 |
142 | if (!diagnostic) continue;
143 |
144 | diagnostics.push(diagnostic);
145 | }
146 |
147 | return diagnostics;
148 | }
149 |
150 | async validateReport(document: Document) {
151 | const diagnostics = await this.validate(document);
152 | const lints = this.#lintMap.get(document.uri) || [];
153 |
154 | await this.connection.sendDiagnostics({
155 | uri: document.uri,
156 | diagnostics: [
157 | ...diagnostics,
158 | ...lints,
159 | ],
160 | });
161 | }
162 |
163 | async validate(document: Document) {
164 | const syntaxDiagnostics = await this.validateTree(document);
165 |
166 | const blockScopedVariables = document.locals.block.flatMap(b => b.symbols.variable);
167 | const unusedVariables = [
168 | ...document.locals.variable,
169 | ...blockScopedVariables,
170 | ]
171 | .filter((variable) => variable.references.length === 0);
172 |
173 | const unusedVariablesDiagnostics = unusedVariables.map((variable): Diagnostic => ({
174 | severity: DiagnosticSeverity.Hint,
175 | range: variable.nameRange,
176 | message: `Unused variable`,
177 | tags: [ DiagnosticTag.Unnecessary ],
178 | }));
179 |
180 | return [
181 | ...syntaxDiagnostics,
182 | ...unusedVariablesDiagnostics,
183 | ];
184 | }
185 |
186 | async lint(uri: DocumentUri) {
187 | if (!this.#twigCodeStyleFixer) {
188 | return;
189 | }
190 |
191 | const lints = await this.#twigCodeStyleFixer.lint(uri) || [];
192 |
193 | if (!lints.length) {
194 | this.#lintMap.delete(uri);
195 | } else {
196 | this.#lintMap.set(uri, lints);
197 | }
198 |
199 | const document = await this.documentCache.get(uri);
200 | const diagnostics = await this.validate(document);
201 |
202 | await this.connection.sendDiagnostics({
203 | uri,
204 | diagnostics: [
205 | ...diagnostics,
206 | ...lints,
207 | ],
208 | });
209 | }
210 |
211 | async lintWorkspace() {
212 | if (!this.#twigCodeStyleFixer) {
213 | return;
214 | }
215 |
216 | const lints = await this.#twigCodeStyleFixer.lintWorkspace() || [];
217 |
218 | for (const { uri, diagnostics } of lints) {
219 | this.#lintMap.set(uri, diagnostics);
220 |
221 | await this.connection.sendDiagnostics({
222 | uri,
223 | diagnostics,
224 | });
225 | }
226 | }
227 | }
228 |
--------------------------------------------------------------------------------
/packages/language-server/src/symbols/LocalSymbolCollector.ts:
--------------------------------------------------------------------------------
1 | import {
2 | LocalSymbolInformation,
3 | TwigBlock,
4 | TwigMacro,
5 | TwigVariableDeclaration,
6 | type TwigImport,
7 | } from './types';
8 | import { getNodeRange, getStringNodeValue } from '../utils/node';
9 | import { SyntaxNode } from 'web-tree-sitter';
10 | import { ITypeResolver } from '../typing/ITypeResolver';
11 | import { ExpressionTypeResolver } from '../typing/ExpressionTypeResolver';
12 | import { toMacro, toVariable, toBlock, toImport } from './nodeToSymbolMapping';
13 | import type { ReflectedType } from '../phpInterop/ReflectedType';
14 |
15 | const nodesToDiveInto: ReadonlySet = new Set([
16 | 'if',
17 | 'elseif',
18 | 'else',
19 | 'output',
20 | 'primary_expression',
21 | 'unary_expression',
22 | 'binary_expression',
23 | 'ternary_expression',
24 | 'subscript_expression',
25 | 'member_expression',
26 | 'filter_expression',
27 | 'parenthesized_expression',
28 | 'call_expression',
29 | 'arguments',
30 | 'object',
31 | 'array',
32 | 'pair',
33 | 'source_elements',
34 | ]);
35 |
36 | export class LocalSymbolCollector {
37 | localSymbols: LocalSymbolInformation = {
38 | variableDefinition: undefined as any, // ctor init
39 | extends: undefined,
40 | variable: [],
41 | macro: [],
42 | block: [],
43 | imports: [],
44 | };
45 |
46 | private readonly exprTypeResolver: ExpressionTypeResolver | null = null;
47 |
48 | constructor(
49 | private readonly tree: SyntaxNode,
50 | private readonly typeResolver: ITypeResolver | null,
51 | variableDefinitionMap: Map = new Map(),
52 | ) {
53 | this.localSymbols.variableDefinition = variableDefinitionMap;
54 |
55 | if (this.typeResolver) {
56 | this.exprTypeResolver = new ExpressionTypeResolver(this.typeResolver);
57 | }
58 | }
59 |
60 | async collect(subtree: SyntaxNode | null = this.tree): Promise {
61 | if (!subtree) {
62 | return this.localSymbols;
63 | }
64 |
65 | const cursor = subtree.walk();
66 | cursor.gotoFirstChild();
67 |
68 | do {
69 | if (nodesToDiveInto.has(cursor.nodeType)) {
70 | await this.collect(cursor.currentNode);
71 | continue;
72 | }
73 |
74 | switch (cursor.nodeType) {
75 | case 'extends': {
76 | this.#visitExtends(cursor.currentNode);
77 | continue;
78 | }
79 |
80 | case 'import': {
81 | this.#visitImport(cursor.currentNode);
82 | continue;
83 | }
84 |
85 | case 'block': {
86 | await this.#visitBlock(cursor.currentNode);
87 | continue;
88 | }
89 |
90 | case 'set':
91 | case 'set_block':
92 | case 'var_declaration': {
93 | await this.#visitVariableDeclaration(cursor.currentNode);
94 | continue;
95 | }
96 |
97 | case 'macro': {
98 | await this.#visitMacro(cursor.currentNode);
99 | continue;
100 | }
101 |
102 | case 'for': {
103 | await this.#visitFor(cursor.currentNode);
104 | continue;
105 | }
106 |
107 | case 'variable': {
108 | await this.#visitVariable(cursor.currentNode);
109 | continue;
110 | }
111 | }
112 | } while (cursor.gotoNextSibling());
113 |
114 | return this.localSymbols;
115 | }
116 |
117 | async #visitFor(currentNode: SyntaxNode) {
118 | const forVariableNodes = currentNode.childrenForFieldName('variable', currentNode.walk());
119 | for (const variable of forVariableNodes) {
120 | this.#visitVariable(variable, false);
121 | }
122 |
123 | const expr = currentNode.childForFieldName('expr');
124 | await this.collect(expr);
125 |
126 | const bodyNode = currentNode.childForFieldName('body');
127 | await this.collect(bodyNode);
128 | }
129 |
130 | async #visitVariable(currentNode: SyntaxNode, isUsedInPlace = true) {
131 | const nameRange = getNodeRange(currentNode);
132 |
133 | const alreadyDefinedVar = this.localSymbols.variableDefinition.get(currentNode.text);
134 | if (alreadyDefinedVar) {
135 | alreadyDefinedVar.references.push(nameRange);
136 | return;
137 | }
138 |
139 | const reflectedType = await this.#reflectVariableDeclarationType(currentNode);
140 | const implicitVariable: TwigVariableDeclaration = {
141 | name: currentNode.text,
142 | nameRange,
143 | range: getNodeRange(currentNode.parent!),
144 | // Implicitly defined variables are used in-place.
145 | // i.e. when you meet a variable for the first time,
146 | // it's "defined" and used in the same place.
147 | references: isUsedInPlace ? [ nameRange ] : [],
148 | reflectedType,
149 | };
150 | this.localSymbols.variable.push(implicitVariable);
151 | this.localSymbols.variableDefinition.set(implicitVariable.name, implicitVariable);
152 | }
153 |
154 | async #visitMacro(currentNode: SyntaxNode) {
155 | const macro = toMacro(currentNode);
156 | const bodyNode = currentNode.childForFieldName('body')!;
157 |
158 | const scopedSymbolCollector = new LocalSymbolCollector(bodyNode, this.typeResolver);
159 | await scopedSymbolCollector.collect();
160 |
161 | const macroWithSymbols: TwigMacro = {
162 | ...macro,
163 | symbols: scopedSymbolCollector.localSymbols,
164 | };
165 | this.localSymbols.macro.push(macroWithSymbols);
166 | }
167 |
168 | async #visitVariableDeclaration(currentNode: SyntaxNode) {
169 | const variableDeclaration = toVariable(currentNode);
170 |
171 | const alreadyDefinedVar = this.localSymbols.variableDefinition.get(variableDeclaration.name);
172 | if (alreadyDefinedVar) {
173 | const nameRange = getNodeRange(currentNode);
174 | alreadyDefinedVar.references.push(nameRange);
175 | } else {
176 | this.localSymbols.variable.push(variableDeclaration);
177 | this.localSymbols.variableDefinition.set(variableDeclaration.name, variableDeclaration);
178 | }
179 |
180 | const valueNode = currentNode.childForFieldName('value');
181 | variableDeclaration.reflectedType = await this.#reflectVariableDeclarationType(currentNode, true);
182 |
183 | await this.collect(valueNode);
184 | }
185 |
186 | async #reflectVariableDeclarationType(varDeclarationNode: SyntaxNode, isDeclaration?: boolean): Promise {
187 | if (!this.exprTypeResolver) return null;
188 |
189 | if (isDeclaration) {
190 | return await this.exprTypeResolver.resolveExpression(varDeclarationNode, this.localSymbols);
191 | }
192 |
193 | const valueNode = varDeclarationNode.childForFieldName('value');
194 | if (valueNode) {
195 | return await this.exprTypeResolver.resolveExpression(
196 | valueNode,
197 | this.localSymbols,
198 | );
199 | }
200 |
201 | const forLoopArrayNode = varDeclarationNode.parent?.type === 'for'
202 | && varDeclarationNode.nextNamedSibling;
203 | if (forLoopArrayNode) {
204 | const forLoopArrayType = await this.exprTypeResolver.resolveExpression(
205 | forLoopArrayNode,
206 | this.localSymbols,
207 | );
208 |
209 | return forLoopArrayType?.arrayType?.itemReflectedType || null;
210 | }
211 |
212 | return null;
213 | }
214 |
215 | async #visitBlock(currentNode: SyntaxNode) {
216 | const block = toBlock(currentNode);
217 | const bodyNode = currentNode.childForFieldName('body')!;
218 |
219 | const scopedSymbolCollector = new LocalSymbolCollector(bodyNode, this.typeResolver, this.localSymbols.variableDefinition);
220 |
221 | const blockWithSymbols: TwigBlock = {
222 | ...block,
223 | symbols: await scopedSymbolCollector.collect(),
224 | };
225 | this.localSymbols.block.push(blockWithSymbols);
226 | }
227 |
228 | #visitImport(currentNode: SyntaxNode) {
229 | const twigImport = toImport(currentNode);
230 | this.localSymbols.imports.push(twigImport);
231 | this.localSymbols.variableDefinition.set(twigImport.name, twigImport);
232 | }
233 |
234 | #visitExtends(currentNode: SyntaxNode) {
235 | const exprNode = currentNode.childForFieldName('expr');
236 |
237 | if (exprNode?.type === 'string') {
238 | this.localSymbols.extends = getStringNodeValue(exprNode);
239 | }
240 | }
241 | }
242 |
--------------------------------------------------------------------------------