├── 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 | ![Definition for variables](https://github.com/moetelo/twiggy/assets/17011936/e24c1d26-1606-4354-a5b4-9d28976c983b) 11 | ![Definition for templates and blocks](https://github.com/moetelo/twiggy/assets/17011936/d192a359-d2c1-471b-bd08-79c847cfeb9e) 12 | 13 | ## Completion 14 | ![Completion](https://github.com/moetelo/twiggy/assets/17011936/b5a7b411-b7c3-4411-b4bb-c3a244dc71f6) 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 | ![inlay hints](https://github.com/moetelo/twiggy/assets/17011936/ae833425-06e9-4c55-84d2-47b152bae51a) 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 | --------------------------------------------------------------------------------