├── AGENTS.md ├── CLAUDE.md ├── internal ├── tree_sitter_grammars │ └── twig │ │ ├── build │ │ ├── node_gyp_bins │ │ │ └── python3 │ │ ├── binding.Makefile │ │ └── Release │ │ │ ├── tree_sitter_twig_binding.node │ │ │ ├── obj.target │ │ │ ├── tree_sitter_twig_binding.node │ │ │ └── tree_sitter_twig_binding │ │ │ │ ├── src │ │ │ │ ├── parser.o │ │ │ │ └── scanner.o │ │ │ │ └── bindings │ │ │ │ └── node │ │ │ │ └── binding.o │ │ │ └── .deps │ │ │ └── Release │ │ │ ├── tree_sitter_twig_binding.node.d │ │ │ └── obj.target │ │ │ ├── tree_sitter_twig_binding.node.d │ │ │ └── tree_sitter_twig_binding │ │ │ └── src │ │ │ ├── parser.o.d │ │ │ └── scanner.o.d │ │ ├── queries │ │ ├── highlights.scm │ │ └── injections.scm │ │ ├── README.md │ │ ├── binding.gyp │ │ ├── bindings │ │ ├── go │ │ │ ├── binding.go │ │ │ └── binding_test.go │ │ ├── node │ │ │ ├── index.js │ │ │ └── binding.cc │ │ └── rust │ │ │ ├── build.rs │ │ │ └── lib.rs │ │ ├── Cargo.toml │ │ ├── corpus │ │ ├── tests.txt │ │ └── directives.txt │ │ ├── src │ │ ├── tree_sitter │ │ │ └── alloc.h │ │ └── scanner.c │ │ └── package.json ├── snippet │ ├── testdata │ │ └── nested.json │ ├── snippet_test.go │ ├── snippet_indexer.go │ └── snippet.go ├── indexer │ ├── indexer.go │ └── treesitter.go ├── lsp │ ├── protocol │ │ ├── error.go │ │ ├── insert_text_format.go │ │ ├── references.go │ │ ├── commands.go │ │ ├── definition.go │ │ ├── codelens.go │ │ ├── hover.go │ │ ├── diagnostics.go │ │ ├── codeaction.go │ │ └── file_sync.go │ ├── references_types.go │ ├── definition_types.go │ ├── diagnostics_provider.go │ ├── codeaction_types.go │ ├── references.go │ ├── definition.go │ ├── hover.go │ ├── completion.go │ ├── codelens.go │ ├── completion │ │ ├── feature_completion.go │ │ ├── route_completion.go │ │ ├── snippet_completion.go │ │ ├── theme_completion.go │ │ └── systemconfig_completion.go │ ├── codeaction │ │ ├── twig_codeaction.go │ │ └── snippet_codeaction.go │ ├── reference │ │ └── route_reference.go │ ├── codelens │ │ └── php_service_codelens.go │ ├── diagnostics │ │ └── snippet_diagnostics.go │ ├── types.go │ ├── definition │ │ ├── route_definition.go │ │ ├── theme_definition.go │ │ └── snippet_definition.go │ └── hover │ │ └── twig_hover.go ├── symfony │ ├── testdata │ │ ├── controller_base.php │ │ └── controller.php │ ├── route_usage_indexer.go │ ├── yaml_routes_test.go │ ├── route_indexer.go │ └── php_routes_test.go ├── theme │ ├── types.go │ ├── theme_indexer_test.go │ ├── parser_test.go │ └── theme_indexer.go ├── tree_sitter_helper │ ├── debug.go │ ├── scss.go │ ├── xml.go │ ├── text_range.go │ ├── twig.go │ └── yaml.go ├── php │ ├── typeinference.go │ ├── testdata │ │ ├── interface.php │ │ ├── typeinference.php │ │ ├── 02.php │ │ ├── inheritance.php │ │ ├── 05.php │ │ ├── 03.php │ │ ├── 01.php │ │ ├── 04.php │ │ └── typeinference_inheritance.php │ ├── ctx.go │ ├── class.go │ ├── group_use_test.go │ ├── treesitter.go │ ├── interface_test.go │ ├── inheritance_test.go │ ├── debug_ast.go │ └── indexer_advanced_test.go ├── extension │ ├── types.go │ ├── testdata │ │ └── manifest.xml │ ├── command.go │ ├── bundle.go │ └── indexer.go ├── twig │ ├── testdata │ │ ├── extension.php │ │ └── extension2.php │ ├── path_test.go │ ├── path.go │ ├── parser_test.go │ └── parser.go ├── feature │ ├── testdata │ │ └── feature.yaml │ ├── feature_test.go │ ├── feature.go │ └── indexer.go └── systemconfig │ └── systemconfig_indexer.go ├── vscode-extension ├── icon.png ├── src │ └── types.ts ├── .vscode │ ├── extensions.json │ ├── launch.json │ └── tasks.json ├── .vscodeignore ├── tsconfig.json ├── LICENSE ├── esbuild.js ├── README.md └── package.json ├── .gitignore ├── cmd └── debug_ast │ └── main.go ├── LICENSE ├── config.go ├── .windsurfrules ├── go.mod ├── .github └── workflows │ ├── release.yml │ └── tests.yml ├── README.md ├── Makefile ├── .goreleaser.yaml └── LSP.md /AGENTS.md: -------------------------------------------------------------------------------- 1 | .windsurfrules -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | .windsurfrules -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/build/node_gyp_bins/python3: -------------------------------------------------------------------------------- 1 | /usr/bin/python3 -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/queries/highlights.scm: -------------------------------------------------------------------------------- 1 | (comment) @comment 2 | -------------------------------------------------------------------------------- /vscode-extension/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopwareLabs/shopware-lsp/HEAD/vscode-extension/icon.png -------------------------------------------------------------------------------- /vscode-extension/src/types.ts: -------------------------------------------------------------------------------- 1 | type SnippetFile = { 2 | name: string; 3 | path: string; 4 | value: string; 5 | }; -------------------------------------------------------------------------------- /vscode-extension/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["connor4312.esbuild-problem-matchers"] 3 | } 4 | -------------------------------------------------------------------------------- /vscode-extension/.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | node_modules 3 | out/ 4 | src/ 5 | tsconfig.json 6 | webpack.config.js 7 | esbuild.js -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/README.md: -------------------------------------------------------------------------------- 1 | # tree-sitter-twig 2 | 3 | Twig grammar for [tree-sitter](https://github.com/tree-sitter/tree-sitter). 4 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/queries/injections.scm: -------------------------------------------------------------------------------- 1 | ((content) @injection.content 2 | (#set! injection.language "html") 3 | (#set! injection.combined)) 4 | -------------------------------------------------------------------------------- /internal/snippet/testdata/nested.json: -------------------------------------------------------------------------------- 1 | { 2 | "foo": { 3 | "name": "title", 4 | "foo": { 5 | "name": "title" 6 | } 7 | } 8 | } -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/build/binding.Makefile: -------------------------------------------------------------------------------- 1 | # This file is generated by gyp; do not edit. 2 | 3 | export builddir_name ?= ./build/. 4 | .PHONY: all 5 | all: 6 | $(MAKE) tree_sitter_twig_binding 7 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/build/Release/tree_sitter_twig_binding.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopwareLabs/shopware-lsp/HEAD/internal/tree_sitter_grammars/twig/build/Release/tree_sitter_twig_binding.node -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /shopware-lsp 2 | node_modules 3 | /vscode-extension/out 4 | /vscode-extension/dist 5 | /dist 6 | /.opencode 7 | internal/php/alias_resolver.go 8 | internal/php/php.db 9 | **/.claude/settings.local.json 10 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/build/Release/obj.target/tree_sitter_twig_binding.node: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopwareLabs/shopware-lsp/HEAD/internal/tree_sitter_grammars/twig/build/Release/obj.target/tree_sitter_twig_binding.node -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/build/Release/obj.target/tree_sitter_twig_binding/src/parser.o: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopwareLabs/shopware-lsp/HEAD/internal/tree_sitter_grammars/twig/build/Release/obj.target/tree_sitter_twig_binding/src/parser.o -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/build/Release/obj.target/tree_sitter_twig_binding/src/scanner.o: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopwareLabs/shopware-lsp/HEAD/internal/tree_sitter_grammars/twig/build/Release/obj.target/tree_sitter_twig_binding/src/scanner.o -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/build/Release/obj.target/tree_sitter_twig_binding/bindings/node/binding.o: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shopwareLabs/shopware-lsp/HEAD/internal/tree_sitter_grammars/twig/build/Release/obj.target/tree_sitter_twig_binding/bindings/node/binding.o -------------------------------------------------------------------------------- /vscode-extension/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2020", 5 | "outDir": "out", 6 | "lib": ["es2020"], 7 | "sourceMap": true, 8 | "rootDir": "src", 9 | "strict": true 10 | }, 11 | "exclude": ["node_modules", ".vscode-test"] 12 | } 13 | -------------------------------------------------------------------------------- /internal/indexer/indexer.go: -------------------------------------------------------------------------------- 1 | package indexer 2 | 3 | import tree_sitter "github.com/tree-sitter/go-tree-sitter" 4 | 5 | type Indexer interface { 6 | ID() string 7 | Index(path string, node *tree_sitter.Node, fileContent []byte) error 8 | RemovedFiles(paths []string) error 9 | Close() error 10 | Clear() error 11 | } 12 | -------------------------------------------------------------------------------- /internal/lsp/protocol/error.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | type ShopwareLspError struct { 4 | Code string `json:"code"` 5 | Message string `json:"message"` 6 | } 7 | 8 | func NewLspError(message string, code string) *ShopwareLspError { 9 | return &ShopwareLspError{ 10 | Code: code, 11 | Message: message, 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/build/Release/.deps/Release/tree_sitter_twig_binding.node.d: -------------------------------------------------------------------------------- 1 | cmd_Release/tree_sitter_twig_binding.node := ln -f "Release/obj.target/tree_sitter_twig_binding.node" "Release/tree_sitter_twig_binding.node" 2>/dev/null || (rm -rf "Release/tree_sitter_twig_binding.node" && cp -af "Release/obj.target/tree_sitter_twig_binding.node" "Release/tree_sitter_twig_binding.node") 2 | -------------------------------------------------------------------------------- /internal/symfony/testdata/controller_base.php: -------------------------------------------------------------------------------- 1 | ") 13 | os.Exit(1) 14 | } 15 | 16 | filePath := os.Args[1] 17 | fmt.Printf("Analyzing AST for file: %s\n\n", filePath) 18 | 19 | php.DebugAST(filePath) 20 | } 21 | -------------------------------------------------------------------------------- /internal/lsp/protocol/insert_text_format.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | // InsertTextFormat defines how inserted text should be interpreted 4 | type InsertTextFormat int 5 | 6 | const ( 7 | // PlainTextFormat indicates the inserted text is interpreted as plain text 8 | PlainTextFormat InsertTextFormat = 1 9 | // SnippetTextFormat indicates the inserted text is interpreted as a snippet 10 | SnippetTextFormat InsertTextFormat = 2 11 | ) 12 | -------------------------------------------------------------------------------- /internal/theme/types.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | // ThemeConfigField represents a field in the theme configuration 4 | type ThemeConfigField struct { 5 | Key string 6 | Label map[string]string 7 | Type string 8 | Value string 9 | Editable bool 10 | Block string 11 | Order int 12 | Path string // Path to the theme.json file 13 | Line int // Line number where the field is defined 14 | Scss bool 15 | } 16 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/binding.gyp: -------------------------------------------------------------------------------- 1 | { 2 | "targets": [ 3 | { 4 | "target_name": "tree_sitter_twig_binding", 5 | "include_dirs": [ 6 | " 5 | // #include "tree_sitter/parser.h" 6 | // 7 | // #define TREE_SITTER_LANGUAGE_VERSION 14 8 | // 9 | // #include "../../src/parser.c" 10 | // #include "../../src/scanner.c" 11 | import "C" 12 | import "unsafe" 13 | 14 | // Language returns the tree-sitter language for Twig. 15 | func Language() unsafe.Pointer { 16 | return unsafe.Pointer(C.tree_sitter_twig()) 17 | } 18 | -------------------------------------------------------------------------------- /vscode-extension/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Run Extension", 6 | "type": "extensionHost", 7 | "request": "launch", 8 | "args": [ 9 | "--extensionDevelopmentPath=${workspaceFolder}" 10 | ], 11 | "outFiles": [ 12 | "${workspaceFolder}/out/**/*.js" 13 | ], 14 | "preLaunchTask": "${defaultBuildTask}", 15 | "env": { 16 | "VSCODE_DEBUG_MODE": "true" 17 | } 18 | } 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /internal/lsp/diagnostics_provider.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 7 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 8 | ) 9 | 10 | // DiagnosticsProvider is an interface for providing diagnostics for a document 11 | type DiagnosticsProvider interface { 12 | // GetDiagnostics returns diagnostics for a document 13 | GetDiagnostics(ctx context.Context, uri string, rootNode *tree_sitter.Node, content []byte) ([]protocol.Diagnostic, error) 14 | } 15 | -------------------------------------------------------------------------------- /internal/php/typeinference.go: -------------------------------------------------------------------------------- 1 | // Package php provides PHP language support for the LSP 2 | package php 3 | 4 | // typeinference.go 5 | // This file is a placeholder for type inference functionality. 6 | // The actual implementation for PHP type inference is in indexer.go 7 | // where the handleMemberCallExpression method now supports: 8 | // 1. Type inference for $this->method() calls 9 | // 2. Full class hierarchy traversal (parent classes and interfaces) 10 | // 3. Method return type resolution through multiple inheritance levels 11 | -------------------------------------------------------------------------------- /internal/symfony/testdata/controller.php: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | Fooo 6 | 7 | A description 8 | Your Company Ltd. 9 | (c) by Your Company Ltd. 10 | 1.0.0 11 | 12 | MIT 13 | 14 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/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 | -------------------------------------------------------------------------------- /internal/php/testdata/typeinference.php: -------------------------------------------------------------------------------- 1 | id; 13 | } 14 | 15 | public function getName(): string 16 | { 17 | return $this->name; 18 | } 19 | 20 | public function setName(string $name): self 21 | { 22 | $this->name = $name; 23 | return $this; 24 | } 25 | 26 | public function getNameWithPrefix(string $prefix): string 27 | { 28 | return $prefix . $this->getName(); 29 | } 30 | 31 | public function getSelf(): self 32 | { 33 | return $this; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/lsp/protocol/references.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import tree_sitter "github.com/tree-sitter/go-tree-sitter" 4 | 5 | // ReferenceParams represents the parameters for a references request 6 | type ReferenceParams struct { 7 | TextDocument struct { 8 | URI string `json:"uri"` 9 | } `json:"textDocument"` 10 | Position struct { 11 | Line int `json:"line"` 12 | Character int `json:"character"` 13 | } `json:"position"` 14 | Context struct { 15 | IncludeDeclaration bool `json:"includeDeclaration"` 16 | } `json:"context"` 17 | // Custom fields for internal use (not part of LSP spec) 18 | // These fields are used to pass document content to reference providers 19 | DocumentContent []byte `json:"-"` 20 | Node *tree_sitter.Node `json:"-"` 21 | } 22 | -------------------------------------------------------------------------------- /internal/twig/testdata/extension.php: -------------------------------------------------------------------------------- 1 | test(...)), 16 | ]; 17 | } 18 | 19 | public function getFilters(): array 20 | { 21 | return [ 22 | new TwigFilter('abs', 'abs'), 23 | new TwigFilter('test', [$this, 'test']), 24 | new TwigFilter('test2', $this->test(...)), 25 | ]; 26 | } 27 | 28 | public function test(string $test) 29 | { 30 | return 'test'; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /internal/lsp/references.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 7 | ) 8 | 9 | // references handles textDocument/references requests 10 | func (s *Server) references(ctx context.Context, params *protocol.ReferenceParams) []protocol.Location { 11 | node, docText, ok := s.documentManager.GetNodeAtPosition(params.TextDocument.URI, params.Position.Line, params.Position.Character) 12 | if ok { 13 | params.Node = node 14 | params.DocumentContent = docText.Text 15 | } 16 | 17 | // Collect reference locations from all providers 18 | var locations []protocol.Location 19 | for _, provider := range s.referencesProviders { 20 | providerLocations := provider.GetReferences(ctx, params) 21 | locations = append(locations, providerLocations...) 22 | } 23 | 24 | return locations 25 | } 26 | -------------------------------------------------------------------------------- /internal/php/testdata/02.php: -------------------------------------------------------------------------------- 1 | request = new Request(); 20 | } 21 | 22 | public function load(string $id): array 23 | { 24 | return ['id' => $id]; 25 | } 26 | 27 | protected function validateId(string $id): bool 28 | { 29 | return strlen($id) > 0; 30 | } 31 | 32 | private function getRepository(): string 33 | { 34 | return $this->productRepository; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/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 | (call_expression 15 | (function) 16 | (arguments 17 | (number)))) 18 | (source_elements 19 | (content)))) 20 | 21 | ================== 22 | Same as 23 | ================== 24 | {% if foo.attribute is same as(false) %} 25 | ... 26 | {% endif %} 27 | --- 28 | (template 29 | (if 30 | (binary_expression 31 | (member_expression 32 | (variable) 33 | (property)) 34 | (call_expression 35 | (function) 36 | (arguments 37 | (boolean)))) 38 | (source_elements 39 | (content)))) 40 | -------------------------------------------------------------------------------- /internal/php/testdata/inheritance.php: -------------------------------------------------------------------------------- 1 | id; 25 | } 26 | 27 | public function getName(): string 28 | { 29 | return $this->name; 30 | } 31 | 32 | public function setName(string $name): self 33 | { 34 | $this->name = $name; 35 | return $this; 36 | } 37 | 38 | // Implementation of Countable interface 39 | public function count(): int 40 | { 41 | return 1; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/bindings/go/binding_test.go: -------------------------------------------------------------------------------- 1 | package twig 2 | 3 | import ( 4 | "testing" 5 | "unsafe" 6 | 7 | treesitter "github.com/tree-sitter/go-tree-sitter" 8 | ) 9 | 10 | func TestLanguage(t *testing.T) { 11 | lang := Language() 12 | if lang == nil { 13 | t.Error("Expected language binding to return a valid pointer") 14 | } 15 | if lang == unsafe.Pointer(nil) { 16 | t.Error("Expected language binding to return a non-nil unsafe pointer") 17 | } 18 | } 19 | 20 | func TestParseTwig(t *testing.T) { 21 | parser := treesitter.NewParser() 22 | if err := parser.SetLanguage(treesitter.NewLanguage(Language())); err != nil { 23 | t.Fatalf("Failed to set language: %v", err) 24 | } 25 | 26 | source := []byte(`{% extends "base.html" %}`) 27 | tree := parser.Parse(source, nil) 28 | 29 | root := tree.RootNode() 30 | if root.Kind() != "template" { 31 | t.Errorf("Expected root node to be 'template', got %s", root.Kind()) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/twig/path_test.go: -------------------------------------------------------------------------------- 1 | package twig 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestConvertToRelativePath(t *testing.T) { 10 | assert.Equal(t, "", ConvertToRelativePath("")) 11 | assert.Equal(t, "", ConvertToRelativePath("/")) 12 | assert.Equal(t, "", ConvertToRelativePath("/Resources/views")) 13 | assert.Equal(t, "", ConvertToRelativePath("/Resources/views/")) 14 | assert.Equal(t, "@Storefront/storefront/base.html.twig", ConvertToRelativePath("/Resources/views/storefront/base.html.twig")) 15 | } 16 | 17 | func TestGetBundleNameByPath(t *testing.T) { 18 | assert.Equal(t, "foo", getBundleNameByPath("foo/Resources/views/storefront/base.html.twig")) 19 | assert.Equal(t, "storefront", getBundleNameByPath("vendor/shopware/storefront/Resources/views/storefront/base.html.twig")) 20 | assert.Equal(t, "MyFoo", getBundleNameByPath("vendor/store.shopware.com/MyFoo/src/Resources/views/storefront/base.html.twig")) 21 | } 22 | -------------------------------------------------------------------------------- /internal/extension/command.go: -------------------------------------------------------------------------------- 1 | package extension 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/shopware/shopware-lsp/internal/lsp" 8 | ) 9 | 10 | type ExtensionCommandProvider struct { 11 | extensionIndex *ExtensionIndexer 12 | } 13 | 14 | func NewExtensionCommandProvider(lsp *lsp.Server) *ExtensionCommandProvider { 15 | extensionIndex, _ := lsp.GetIndexer("extension.indexer") 16 | 17 | return &ExtensionCommandProvider{ 18 | extensionIndex: extensionIndex.(*ExtensionIndexer), 19 | } 20 | } 21 | func (e *ExtensionCommandProvider) GetCommands(ctx context.Context) map[string]lsp.CommandFunc { 22 | return map[string]lsp.CommandFunc{ 23 | "shopware/extension/all": e.allExtensions, 24 | } 25 | } 26 | 27 | func (e *ExtensionCommandProvider) allExtensions(ctx context.Context, args *json.RawMessage) (interface{}, error) { 28 | extensions, err := e.extensionIndex.GetAll() 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | return extensions, nil 34 | } 35 | -------------------------------------------------------------------------------- /vscode-extension/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "watch", 6 | "dependsOn": ["npm: watch:tsc", "npm: watch:esbuild"], 7 | "presentation": { 8 | "reveal": "never" 9 | }, 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | } 14 | }, 15 | { 16 | "type": "npm", 17 | "script": "watch:esbuild", 18 | "group": "build", 19 | "problemMatcher": "$esbuild-watch", 20 | "isBackground": true, 21 | "label": "npm: watch:esbuild", 22 | "presentation": { 23 | "group": "watch", 24 | "reveal": "never" 25 | } 26 | }, 27 | { 28 | "type": "npm", 29 | "script": "watch:tsc", 30 | "group": "build", 31 | "problemMatcher": "$tsc-watch", 32 | "isBackground": true, 33 | "label": "npm: watch:tsc", 34 | "presentation": { 35 | "group": "watch", 36 | "reveal": "never" 37 | } 38 | } 39 | ] 40 | } -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/bindings/node/binding.cc: -------------------------------------------------------------------------------- 1 | #include "tree_sitter/parser.h" 2 | #include 3 | #include "nan.h" 4 | 5 | using namespace v8; 6 | 7 | extern "C" TSLanguage * tree_sitter_twig(); 8 | 9 | namespace { 10 | 11 | NAN_METHOD(New) {} 12 | 13 | void Init(Local exports, Local module) { 14 | Local tpl = Nan::New(New); 15 | tpl->SetClassName(Nan::New("Language").ToLocalChecked()); 16 | tpl->InstanceTemplate()->SetInternalFieldCount(1); 17 | 18 | Local constructor = Nan::GetFunction(tpl).ToLocalChecked(); 19 | Local instance = constructor->NewInstance(Nan::GetCurrentContext()).ToLocalChecked(); 20 | Nan::SetInternalFieldPointer(instance, 0, tree_sitter_twig()); 21 | 22 | Nan::Set(instance, Nan::New("name").ToLocalChecked(), Nan::New("twig").ToLocalChecked()); 23 | Nan::Set(module, Nan::New("exports").ToLocalChecked(), instance); 24 | } 25 | 26 | NODE_MODULE(tree_sitter_twig_binding, Init) 27 | 28 | } // namespace 29 | -------------------------------------------------------------------------------- /internal/twig/path.go: -------------------------------------------------------------------------------- 1 | package twig 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | func ConvertToRelativePath(twigPath string) string { 10 | index := strings.Index(twigPath, "Resources/views") 11 | if index != -1 { 12 | path := strings.TrimPrefix(strings.TrimPrefix(twigPath[index:], "Resources/views"), "/") 13 | 14 | if path == "" { 15 | return "" 16 | } 17 | 18 | return fmt.Sprintf("@Storefront/%s", path) 19 | } 20 | 21 | path := strings.TrimPrefix(twigPath, "/") 22 | 23 | if path == "" { 24 | return "" 25 | } 26 | 27 | return fmt.Sprintf("@Storefront/%s", path) 28 | } 29 | 30 | func getBundleNameByPath(twigPath string) string { 31 | index := strings.Index(twigPath, "Resources/views") 32 | if index != -1 { 33 | possiblePath := strings.Trim(twigPath[:index], "/") 34 | 35 | if filepath.Base(possiblePath) == "src" { 36 | return filepath.Base(filepath.Dir(possiblePath)) 37 | } 38 | 39 | return filepath.Base(possiblePath) 40 | } 41 | 42 | return "unknown" 43 | } 44 | -------------------------------------------------------------------------------- /internal/lsp/protocol/commands.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | // CommandRequest represents a custom command request 4 | type CommandRequest struct { 5 | Command string `json:"command"` 6 | Arguments []interface{} `json:"arguments"` 7 | } 8 | 9 | // RequestInputParams represents the parameters for a request input request 10 | type RequestInputParams struct { 11 | Prompt string `json:"prompt"` 12 | PlaceHolder string `json:"placeHolder,omitempty"` 13 | DefaultValue string `json:"defaultValue,omitempty"` 14 | } 15 | 16 | // RequestInputResponse represents the response for a request input request 17 | type RequestInputResponse struct { 18 | Value string `json:"value"` 19 | } 20 | 21 | // MultipleInputsParams represents the parameters for a multiple inputs request 22 | type MultipleInputsParams struct { 23 | Items []RequestInputParams `json:"items"` 24 | } 25 | 26 | // MultipleInputsResponse represents the response for a multiple inputs request 27 | type MultipleInputsResponse struct { 28 | Values []string `json:"values"` 29 | } 30 | -------------------------------------------------------------------------------- /internal/php/ctx.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "context" 5 | 6 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 7 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 8 | ) 9 | 10 | // phpContextKey is a custom type for the context key to avoid collisions 11 | type phpContextKey string 12 | 13 | // PHPContextKey is the key used to store PHP context in the context.Context 14 | const PHPContextKey phpContextKey = "php.context" 15 | 16 | type PHPContext struct { 17 | InsideClass *PHPClass 18 | Node *tree_sitter.Node 19 | } 20 | 21 | func GetPHPContext(ctx context.Context) *PHPContext { 22 | return ctx.Value(PHPContextKey).(*PHPContext) 23 | } 24 | 25 | func (p *PHPIndex) AddContext(ctx context.Context, node *tree_sitter.Node, documentContent []byte) context.Context { 26 | className := treesitterhelper.GetClassName(node, documentContent) 27 | class := p.GetClass(className) 28 | 29 | return context.WithValue(ctx, PHPContextKey, &PHPContext{ 30 | InsideClass: class, 31 | Node: node, 32 | }) 33 | } 34 | -------------------------------------------------------------------------------- /internal/tree_sitter_helper/xml.go: -------------------------------------------------------------------------------- 1 | package treesitterhelper 2 | 3 | import ( 4 | "strings" 5 | 6 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 7 | ) 8 | 9 | func GetFirstNodeOfKind(node *tree_sitter.Node, kind string) *tree_sitter.Node { 10 | for i := 0; i < int(node.ChildCount()); i++ { 11 | child := node.Child(uint(i)) 12 | if child.Kind() == kind { 13 | return child 14 | } 15 | } 16 | return nil 17 | } 18 | 19 | func GetXmlAttributeValues(node *tree_sitter.Node, documentText []byte) map[string]string { 20 | result := make(map[string]string) 21 | 22 | for i := 0; i < int(node.NamedChildCount()); i++ { 23 | child := node.NamedChild(uint(i)) 24 | if child.Kind() == "Attribute" { 25 | nameNode := GetFirstNodeOfKind(child, "Name") 26 | valueNode := GetFirstNodeOfKind(child, "AttValue") 27 | 28 | if nameNode != nil && valueNode != nil { 29 | name := nameNode.Utf8Text(documentText) 30 | value := valueNode.Utf8Text(documentText) 31 | result[name] = strings.Trim(value, "\"") 32 | } 33 | } 34 | } 35 | 36 | return result 37 | } 38 | -------------------------------------------------------------------------------- /internal/php/testdata/05.php: -------------------------------------------------------------------------------- 1 | request = new SymfonyRequest(); 24 | $this->productLoader = new Loader('test'); 25 | $this->connection = new Connection([]); 26 | } 27 | 28 | public function getLoader(): Loader 29 | { 30 | return $this->productLoader; 31 | } 32 | 33 | protected function validateRequest(SymfonyRequest $request): bool 34 | { 35 | return $request->isMethod('GET'); 36 | } 37 | 38 | private function getConnection(): Connection 39 | { 40 | return $this->connection; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /internal/twig/testdata/extension2.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | public function getFunctions(): array 24 | { 25 | return [ 26 | new TwigFunction('inAppPurchase', $this->isActive(...)), 27 | new TwigFunction('allInAppPurchases', $this->all(...)), 28 | ]; 29 | } 30 | 31 | public function isActive(string $extensionName, string $identifier): bool 32 | { 33 | return $this->inAppPurchase->isActive($extensionName, $identifier); 34 | } 35 | 36 | /** 37 | * @return list 38 | */ 39 | public function all(): array 40 | { 41 | return $this->inAppPurchase->formatPurchases(); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/src/tree_sitter/alloc.h: -------------------------------------------------------------------------------- 1 | #ifndef TREE_SITTER_ALLOC_H_ 2 | #define TREE_SITTER_ALLOC_H_ 3 | 4 | #ifdef __cplusplus 5 | extern "C" { 6 | #endif 7 | 8 | #include 9 | #include 10 | #include 11 | 12 | // Allow clients to override allocation functions 13 | #ifdef TREE_SITTER_REUSE_ALLOCATOR 14 | 15 | extern void *(*ts_current_malloc)(size_t size); 16 | extern void *(*ts_current_calloc)(size_t count, size_t size); 17 | extern void *(*ts_current_realloc)(void *ptr, size_t size); 18 | extern void (*ts_current_free)(void *ptr); 19 | 20 | #ifndef ts_malloc 21 | #define ts_malloc ts_current_malloc 22 | #endif 23 | #ifndef ts_calloc 24 | #define ts_calloc ts_current_calloc 25 | #endif 26 | #ifndef ts_realloc 27 | #define ts_realloc ts_current_realloc 28 | #endif 29 | #ifndef ts_free 30 | #define ts_free ts_current_free 31 | #endif 32 | 33 | #else 34 | 35 | #ifndef ts_malloc 36 | #define ts_malloc malloc 37 | #endif 38 | #ifndef ts_calloc 39 | #define ts_calloc calloc 40 | #endif 41 | #ifndef ts_realloc 42 | #define ts_realloc realloc 43 | #endif 44 | #ifndef ts_free 45 | #define ts_free free 46 | #endif 47 | 48 | #endif 49 | 50 | #ifdef __cplusplus 51 | } 52 | #endif 53 | 54 | #endif // TREE_SITTER_ALLOC_H_ 55 | -------------------------------------------------------------------------------- /config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/user" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | func getProjectCacheFolder(projectRoot string) (string, error) { 12 | configDir, err := getUserCacheDir() 13 | if err != nil { 14 | return "", err 15 | } 16 | 17 | projectSlug := strings.ReplaceAll(projectRoot, "/", "_") 18 | projectSlug = strings.ReplaceAll(projectSlug, ":", "_") 19 | projectSlug = strings.ReplaceAll(projectSlug, "\\", "_") 20 | 21 | expectedDir := filepath.Join(configDir, "shopware-lsp", projectSlug) 22 | 23 | if _, err := os.Stat(expectedDir); err != nil { 24 | if !os.IsNotExist(err) { 25 | return "", fmt.Errorf("failed to check directory: %w", err) 26 | } 27 | // Directory does not exist, create it 28 | err = os.MkdirAll(expectedDir, 0755) 29 | if err != nil { 30 | return "", fmt.Errorf("failed to create directory: %w", err) 31 | } 32 | } 33 | 34 | return expectedDir, nil 35 | } 36 | 37 | func getUserCacheDir() (string, error) { 38 | configDir, err := os.UserCacheDir() 39 | if err != nil { 40 | usr, err := user.Current() 41 | if err != nil { 42 | return "", fmt.Errorf("failed to get current user: %w", err) 43 | } 44 | return filepath.Join(usr.HomeDir, ".config"), nil 45 | } 46 | return configDir, nil 47 | } 48 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/build/Release/.deps/Release/obj.target/tree_sitter_twig_binding/src/parser.o.d: -------------------------------------------------------------------------------- 1 | cmd_Release/obj.target/tree_sitter_twig_binding/src/parser.o := cc -o Release/obj.target/tree_sitter_twig_binding/src/parser.o ../src/parser.c '-DNODE_GYP_MODULE_NAME=tree_sitter_twig_binding' '-DUSING_UV_SHARED=1' '-DUSING_V8_SHARED=1' '-DV8_DEPRECATION_WARNINGS=1' '-DV8_DEPRECATION_WARNINGS' '-DV8_IMMINENT_DEPRECATION_WARNINGS' '-D_GLIBCXX_USE_CXX11_ABI=1' '-D_LARGEFILE_SOURCE' '-D_FILE_OFFSET_BITS=64' '-D__STDC_FORMAT_MACROS' '-DOPENSSL_NO_PINSHARED' '-DOPENSSL_THREADS' '-DBUILDING_NODE_EXTENSION' -I/root/.cache/node-gyp/18.16.0/include/node -I/root/.cache/node-gyp/18.16.0/src -I/root/.cache/node-gyp/18.16.0/deps/openssl/config -I/root/.cache/node-gyp/18.16.0/deps/openssl/openssl/include -I/root/.cache/node-gyp/18.16.0/deps/uv/include -I/root/.cache/node-gyp/18.16.0/deps/zlib -I/root/.cache/node-gyp/18.16.0/deps/v8/include -I../node_modules/nan -I../src -fPIC -pthread -Wall -Wextra -Wno-unused-parameter -m64 -O3 -fno-omit-frame-pointer -std=c99 -MMD -MF ./Release/.deps/Release/obj.target/tree_sitter_twig_binding/src/parser.o.d.raw -c 2 | Release/obj.target/tree_sitter_twig_binding/src/parser.o: ../src/parser.c \ 3 | ../src/tree_sitter/parser.h 4 | ../src/parser.c: 5 | ../src/tree_sitter/parser.h: 6 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/build/Release/.deps/Release/obj.target/tree_sitter_twig_binding/src/scanner.o.d: -------------------------------------------------------------------------------- 1 | cmd_Release/obj.target/tree_sitter_twig_binding/src/scanner.o := cc -o Release/obj.target/tree_sitter_twig_binding/src/scanner.o ../src/scanner.c '-DNODE_GYP_MODULE_NAME=tree_sitter_twig_binding' '-DUSING_UV_SHARED=1' '-DUSING_V8_SHARED=1' '-DV8_DEPRECATION_WARNINGS=1' '-DV8_DEPRECATION_WARNINGS' '-DV8_IMMINENT_DEPRECATION_WARNINGS' '-D_GLIBCXX_USE_CXX11_ABI=1' '-D_LARGEFILE_SOURCE' '-D_FILE_OFFSET_BITS=64' '-D__STDC_FORMAT_MACROS' '-DOPENSSL_NO_PINSHARED' '-DOPENSSL_THREADS' '-DBUILDING_NODE_EXTENSION' -I/root/.cache/node-gyp/18.16.0/include/node -I/root/.cache/node-gyp/18.16.0/src -I/root/.cache/node-gyp/18.16.0/deps/openssl/config -I/root/.cache/node-gyp/18.16.0/deps/openssl/openssl/include -I/root/.cache/node-gyp/18.16.0/deps/uv/include -I/root/.cache/node-gyp/18.16.0/deps/zlib -I/root/.cache/node-gyp/18.16.0/deps/v8/include -I../node_modules/nan -I../src -fPIC -pthread -Wall -Wextra -Wno-unused-parameter -m64 -O3 -fno-omit-frame-pointer -std=c99 -MMD -MF ./Release/.deps/Release/obj.target/tree_sitter_twig_binding/src/scanner.o.d.raw -c 2 | Release/obj.target/tree_sitter_twig_binding/src/scanner.o: \ 3 | ../src/scanner.c ../src/tree_sitter/parser.h 4 | ../src/scanner.c: 5 | ../src/tree_sitter/parser.h: 6 | -------------------------------------------------------------------------------- /internal/lsp/codelens.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 7 | ) 8 | 9 | // codeLens handles textDocument/codeLens requests 10 | func (s *Server) codeLens(ctx context.Context, params *protocol.CodeLensParams) []protocol.CodeLens { 11 | // Check if document exists 12 | _, ok := s.documentManager.GetDocument(params.TextDocument.URI) 13 | if !ok { 14 | return nil 15 | } 16 | 17 | // Collect code lenses from all providers 18 | var lenses []protocol.CodeLens 19 | for _, provider := range s.codeLensProviders { 20 | providerLenses := provider.GetCodeLenses(ctx, params) 21 | lenses = append(lenses, providerLenses...) 22 | } 23 | 24 | return lenses 25 | } 26 | 27 | // resolveCodeLens handles codeLens/resolve requests 28 | func (s *Server) resolveCodeLens(ctx context.Context, codeLens *protocol.CodeLens) (*protocol.CodeLens, error) { 29 | // Find a provider that can resolve this code lens 30 | for _, provider := range s.codeLensProviders { 31 | resolved, err := provider.ResolveCodeLens(ctx, codeLens) 32 | if err != nil { 33 | return nil, err 34 | } 35 | if resolved != nil { 36 | return resolved, nil 37 | } 38 | } 39 | 40 | // If no provider could resolve it, return the original 41 | return codeLens, nil 42 | } 43 | -------------------------------------------------------------------------------- /internal/feature/testdata/feature.yaml: -------------------------------------------------------------------------------- 1 | shopware: 2 | feature: 3 | flags: 4 | - name: v6.5.0.0 5 | default: true 6 | major: true 7 | toggleable: false 8 | - name: v6.6.0.0 9 | default: true 10 | major: true 11 | toggleable: false 12 | - name: v6.7.0.0 13 | default: true 14 | major: true 15 | toggleable: false 16 | - name: v6.8.0.0 17 | default: false 18 | major: true 19 | toggleable: false 20 | - name: DISABLE_VUE_COMPAT 21 | default: true 22 | major: true 23 | toggleable: false 24 | - name: ACCESSIBILITY_TWEAKS 25 | default: true 26 | major: true 27 | toggleable: true 28 | description: "Accessibility tweaks (highly recommended)\nAccessibility improvements can alter the HTML and CSS structure of the template.\nThe accessibility improvements will be standard as of v6.7.0" 29 | - name: TELEMETRY_METRICS 30 | default: false 31 | major: false 32 | toggleable: true 33 | description: "Experimental! Enable OpenTelemetry metrics" 34 | - name: FLOW_EXECUTION_AFTER_BUSINESS_PROCESS 35 | default: false 36 | major: false 37 | toggleable: true 38 | description: "Experimental! Execute flows after the main unit of work has concluded" 39 | -------------------------------------------------------------------------------- /internal/php/testdata/01.php: -------------------------------------------------------------------------------- 1 | treeItem = new TreeItem(null, []); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /internal/php/testdata/04.php: -------------------------------------------------------------------------------- 1 | request; 30 | } 31 | 32 | // Method with intersection type parameter (PHP 8.1+) 33 | public function processConnection(DbConnection&Repository $connection): void 34 | { 35 | // Implementation 36 | } 37 | 38 | // Method with nullable type 39 | public function getOptionalKernel(): ?Kernel 40 | { 41 | return $this->kernel; 42 | } 43 | 44 | // Method with mixed return type 45 | public function getData(): mixed 46 | { 47 | return []; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.windsurfrules: -------------------------------------------------------------------------------- 1 | # Shopware LSP Development Guidelines 2 | 3 | ## Build Commands 4 | - Build: `go build` 5 | - Test all: `go test ./...` 6 | - Test single package: `go test ./internal/feature` 7 | - Test with race detection: `go test -race ./...` 8 | - Lint (install golangci-lint if needed): `golangci-lint run` 9 | 10 | ## Code Style 11 | - Follow standard Go formatting: use `gofmt` or `goimports` 12 | - Import order: standard library, external packages, internal packages 13 | - Use explicit error handling, no panics in production code 14 | - Function naming: CamelCase (PascalCase for exports, camelCase for private) 15 | - Variable naming: short but descriptive, camelCase 16 | - Tests: use testify/assert and testify/require, no mocks 17 | - Context: use t.Context() in tests, t.TempDir() for temporary directories 18 | 19 | ## Error Handling 20 | - Return errors, don't log and continue 21 | - Use descriptive error messages with context 22 | - Wrap errors when adding context 23 | - Check all errors from external functions 24 | 25 | ## Project Structure 26 | - Keep packages small and focused on a single responsibility 27 | - Favor composition over inheritance 28 | - Implement interfaces for testability 29 | - Use tree-sitter for parsing different file formats 30 | 31 | ## Tests 32 | 33 | - Don't mock things 34 | - Use testify assert instead of regular if conditions 35 | - Use t.Context for context and t.TempDir for an temporary directory 36 | -------------------------------------------------------------------------------- /internal/lsp/protocol/codelens.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | // CodeLensParams represents the parameters for a code lens request 4 | type CodeLensParams struct { 5 | // The document to request code lenses for 6 | TextDocument struct { 7 | URI string `json:"uri"` 8 | } `json:"textDocument"` 9 | // An optional token that a server can use to report work done progress 10 | WorkDoneToken interface{} `json:"workDoneToken,omitempty"` 11 | // An optional token that a server can use to report partial results 12 | PartialResultToken interface{} `json:"partialResultToken,omitempty"` 13 | } 14 | 15 | // CodeLens represents a command that should be shown along with source text 16 | type CodeLens struct { 17 | // The range in which this code lens is valid. Should only span a single line 18 | Range Range `json:"range"` 19 | // The command this code lens represents 20 | Command *Command `json:"command,omitempty"` 21 | // A data entry field that is preserved on a code lens item between a code lens and a code lens resolve request 22 | Data interface{} `json:"data,omitempty"` 23 | } 24 | 25 | // Command represents a reference to a command 26 | type Command struct { 27 | // Title of the command, like `save` 28 | Title string `json:"title"` 29 | // The identifier of the actual command handler 30 | Command string `json:"command"` 31 | // Arguments that the command handler should be invoked with 32 | Arguments []interface{} `json:"arguments,omitempty"` 33 | } 34 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/shopware/shopware-lsp 2 | 3 | go 1.25.3 4 | 5 | replace github.com/tree-sitter-grammars/tree-sitter-xml => github.com/justinMBullard/tree-sitter-xml v0.0.0-20250305015746-03d1af911bbd 6 | 7 | replace github.com/tree-sitter-grammars/tree-sitter-scss => github.com/shyim/tree-sitter-scss v0.0.0-20250502122635-ca898ab73795 8 | 9 | require ( 10 | github.com/cespare/xxhash/v2 v2.3.0 11 | github.com/fsnotify/fsnotify v1.9.0 12 | github.com/sourcegraph/jsonrpc2 v0.2.1 13 | github.com/stretchr/testify v1.10.0 14 | github.com/tidwall/pretty v1.2.1 15 | github.com/tidwall/sjson v1.2.5 16 | github.com/tree-sitter-grammars/tree-sitter-scss v1.0.0 17 | github.com/tree-sitter-grammars/tree-sitter-xml v0.7.0 18 | github.com/tree-sitter-grammars/tree-sitter-yaml v0.7.2 19 | github.com/tree-sitter/go-tree-sitter v0.25.0 20 | github.com/tree-sitter/tree-sitter-json v0.24.8 21 | github.com/tree-sitter/tree-sitter-php v0.24.2 22 | github.com/vmihailenco/msgpack/v5 v5.4.1 23 | go.etcd.io/bbolt v1.4.3 24 | ) 25 | 26 | require ( 27 | github.com/davecgh/go-spew v1.1.1 // indirect 28 | github.com/mattn/go-pointer v0.0.1 // indirect 29 | github.com/pmezard/go-difflib v1.0.0 // indirect 30 | github.com/tidwall/gjson v1.18.0 // indirect 31 | github.com/tidwall/match v1.2.0 // indirect 32 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 33 | golang.org/x/sync v0.13.0 // indirect 34 | golang.org/x/sys v0.38.0 // indirect 35 | gopkg.in/yaml.v3 v3.0.1 // indirect 36 | ) 37 | -------------------------------------------------------------------------------- /vscode-extension/esbuild.js: -------------------------------------------------------------------------------- 1 | const esbuild = require('esbuild'); 2 | 3 | const production = process.argv.includes('--production'); 4 | const watch = process.argv.includes('--watch'); 5 | 6 | async function main() { 7 | const ctx = await esbuild.context({ 8 | entryPoints: ['src/extension.ts'], 9 | bundle: true, 10 | format: 'cjs', 11 | minify: production, 12 | sourcemap: !production, 13 | sourcesContent: false, 14 | platform: 'node', 15 | outfile: 'dist/extension.js', 16 | external: ['vscode'], 17 | logLevel: 'warning', 18 | plugins: [ 19 | /* add to the end of plugins array */ 20 | esbuildProblemMatcherPlugin 21 | ] 22 | }); 23 | if (watch) { 24 | await ctx.watch(); 25 | } else { 26 | await ctx.rebuild(); 27 | await ctx.dispose(); 28 | } 29 | } 30 | 31 | /** 32 | * @type {import('esbuild').Plugin} 33 | */ 34 | const esbuildProblemMatcherPlugin = { 35 | name: 'esbuild-problem-matcher', 36 | 37 | setup(build) { 38 | build.onStart(() => { 39 | console.log('[watch] build started'); 40 | }); 41 | build.onEnd(result => { 42 | result.errors.forEach(({ text, location }) => { 43 | console.error(`✘ [ERROR] ${text}`); 44 | if (location == null) return; 45 | console.error(` ${location.file}:${location.line}:${location.column}:`); 46 | }); 47 | console.log('[watch] build finished'); 48 | }); 49 | } 50 | }; 51 | 52 | main().catch(e => { 53 | console.error(e); 54 | process.exit(1); 55 | }); -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/bindings/rust/build.rs: -------------------------------------------------------------------------------- 1 | fn main() { 2 | let src_dir = std::path::Path::new("src"); 3 | 4 | let mut c_config = cc::Build::new(); 5 | c_config.include(&src_dir); 6 | c_config 7 | .flag_if_supported("-Wno-unused-parameter") 8 | .flag_if_supported("-Wno-unused-but-set-variable") 9 | .flag_if_supported("-Wno-trigraphs"); 10 | let parser_path = src_dir.join("parser.c"); 11 | c_config.file(&parser_path); 12 | 13 | // If your language uses an external scanner written in C, 14 | // then include this block of code: 15 | 16 | /* 17 | let scanner_path = src_dir.join("scanner.c"); 18 | c_config.file(&scanner_path); 19 | println!("cargo:rerun-if-changed={}", scanner_path.to_str().unwrap()); 20 | */ 21 | 22 | c_config.compile("parser"); 23 | println!("cargo:rerun-if-changed={}", parser_path.to_str().unwrap()); 24 | 25 | // If your language uses an external scanner written in C++, 26 | // then include this block of code: 27 | 28 | /* 29 | let mut cpp_config = cc::Build::new(); 30 | cpp_config.cpp(true); 31 | cpp_config.include(&src_dir); 32 | cpp_config 33 | .flag_if_supported("-Wno-unused-parameter") 34 | .flag_if_supported("-Wno-unused-but-set-variable"); 35 | let scanner_path = src_dir.join("scanner.cc"); 36 | cpp_config.file(&scanner_path); 37 | cpp_config.compile("scanner"); 38 | println!("cargo:rerun-if-changed={}", scanner_path.to_str().unwrap()); 39 | */ 40 | } 41 | -------------------------------------------------------------------------------- /internal/php/group_use_test.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestGroupUseStatements(t *testing.T) { 11 | idx, err := NewPHPIndex(t.TempDir()) 12 | assert.NoError(t, err) 13 | path := filepath.Join("testdata", "05.php") 14 | classes := idx.GetClassesOfFile(path) 15 | 16 | // Verify the class was found 17 | assert.Contains(t, classes, "App\\Controller\\TestController") 18 | class := classes["App\\Controller\\TestController"] 19 | 20 | // Check property types are correctly resolved 21 | expectedTypes := map[string]string{ 22 | "request": "Symfony\\Component\\HttpFoundation\\Request", 23 | "response": "Symfony\\Component\\HttpFoundation\\Response", 24 | "kernel": "Symfony\\Component\\HttpKernel\\Kernel", 25 | "connection": "Doctrine\\DBAL\\Connection", 26 | "statement": "Doctrine\\DBAL\\Statement", 27 | "context": "Shopware\\Core\\Framework\\Context", 28 | "repository": "Shopware\\Core\\Framework\\DataAbstractionLayer\\EntityRepository", 29 | "criteria": "Shopware\\Core\\Framework\\DataAbstractionLayer\\Search\\Criteria", 30 | "filter": "Shopware\\Core\\Framework\\DataAbstractionLayer\\Search\\Filter\\EqualsFilter", 31 | } 32 | 33 | for propName, expectedType := range expectedTypes { 34 | assert.Contains(t, class.Properties, propName, "Property %s should exist", propName) 35 | assert.Equal(t, expectedType, class.Properties[propName].Type.Name(), "Property %s should have type %s", propName, expectedType) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | push: 4 | tags: 5 | - "*" 6 | 7 | permissions: 8 | id-token: write 9 | contents: write 10 | 11 | jobs: 12 | release-lsp: 13 | name: Release LSP 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v5 18 | with: 19 | fetch-depth: 0 20 | 21 | - name: Gather Homebrew Token 22 | uses: octo-sts/action@6177b4481c00308b3839969c3eca88c96a91775f # ratchet:octo-sts/action@v1.0.0 23 | id: sts-shopware 24 | with: 25 | scope: shopware/homebrew-tap 26 | identity: lsp 27 | 28 | - name: Release 29 | run: make release 30 | env: 31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 32 | HOMEBREW_TAP_GITHUB_TOKEN: ${{ steps.sts-shopware.outputs.token }} 33 | 34 | build-vscode: 35 | name: Build VSCode 36 | runs-on: ubuntu-latest 37 | needs: release-lsp 38 | strategy: 39 | fail-fast: false 40 | matrix: 41 | os: [darwin, linux, alpine] 42 | arch: [amd64, arm64] 43 | steps: 44 | - name: Checkout code 45 | uses: actions/checkout@v5 46 | 47 | - name: Build VSCode 48 | run: make release-build-extension VERSION=${{ github.ref_name }} OS=${{ matrix.os }} ARCH=${{ matrix.arch }} PUBLISH=1 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | VSCE_PAT: ${{ secrets.VSCODE_PUBLISH_TOKEN }} 52 | OVSX_PAT: ${{ secrets.OVSX_PUBLISH_TOKEN }} 53 | -------------------------------------------------------------------------------- /internal/php/treesitter.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "context" 5 | "slices" 6 | 7 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 8 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 9 | ) 10 | 11 | func (s *PHPIndex) IsMethodCalledName(ctx context.Context, node *tree_sitter.Node, content []byte, methodNames ...string) bool { 12 | current := node 13 | for current != nil && current.Kind() != "member_call_expression" { 14 | current = current.Parent() 15 | } 16 | 17 | if current == nil { 18 | return false 19 | } 20 | 21 | methodNameNode := treesitterhelper.GetFirstNodeOfKind(current, "name") 22 | if methodNameNode == nil { 23 | return false 24 | } 25 | 26 | return slices.Contains(methodNames, string(methodNameNode.Utf8Text(content))) 27 | } 28 | 29 | func (s *PHPIndex) IsMethodCalledOnClass(ctx context.Context, node *tree_sitter.Node, content []byte, className string) bool { 30 | current := node 31 | for current != nil && current.Kind() != "member_call_expression" { 32 | current = current.Parent() 33 | } 34 | 35 | if current == nil { 36 | return false 37 | } 38 | 39 | // Get context information safely - check if PHPContext exists in the context 40 | _, ok := ctx.Value(PHPContextKey).(*PHPContext) 41 | if !ok { 42 | // If we don't have the necessary context, we can't determine the class type 43 | return false 44 | } 45 | 46 | nodeType := s.GetTypeOfNode(ctx, current, content) 47 | if nodeType == nil { 48 | return false 49 | } 50 | 51 | return nodeType.Matches(NewPHPType(className)) 52 | } 53 | -------------------------------------------------------------------------------- /internal/tree_sitter_helper/text_range.go: -------------------------------------------------------------------------------- 1 | package treesitterhelper 2 | 3 | import ( 4 | "bytes" 5 | "strings" 6 | 7 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 8 | ) 9 | 10 | // GetTextForRange extracts text from the document content for the given range 11 | func GetTextForRange(content []byte, rng protocol.Range) string { 12 | if len(content) == 0 { 13 | return "" 14 | } 15 | 16 | lines := bytes.Split(content, []byte("\n")) 17 | if len(lines) == 0 || int(rng.Start.Line) >= len(lines) || int(rng.End.Line) >= len(lines) { 18 | return "" 19 | } 20 | 21 | if rng.Start.Line == rng.End.Line { 22 | // Selection is on a single line 23 | line := lines[rng.Start.Line] 24 | if int(rng.Start.Character) >= len(line) || int(rng.End.Character) > len(line) { 25 | return "" 26 | } 27 | return string(line[rng.Start.Character:rng.End.Character]) 28 | } 29 | 30 | // Selection spans multiple lines 31 | var result []string 32 | 33 | // First line from start character to end of line 34 | firstLine := lines[rng.Start.Line] 35 | if int(rng.Start.Character) < len(firstLine) { 36 | result = append(result, string(firstLine[rng.Start.Character:])) 37 | } 38 | 39 | // Middle lines (if any) in full 40 | for i := rng.Start.Line + 1; i < rng.End.Line; i++ { 41 | result = append(result, string(lines[i])) 42 | } 43 | 44 | // Last line from start of line to end character 45 | lastLine := lines[rng.End.Line] 46 | if int(rng.End.Character) <= len(lastLine) { 47 | result = append(result, string(lastLine[:rng.End.Character])) 48 | } 49 | 50 | return strings.Join(result, "\n") 51 | } 52 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tree-sitter-twig", 3 | "repository": "https://github.com/kaermorchen/tree-sitter-twig", 4 | "version": "0.7.0", 5 | "description": "Twig grammar for tree-sitter", 6 | "main": "bindings/node", 7 | "scripts": { 8 | "build": "tree-sitter generate", 9 | "build-wasm": "tree-sitter build-wasm", 10 | "test": "tree-sitter test", 11 | "release": "release-it" 12 | }, 13 | "author": "Stanislav Romanov ", 14 | "license": "Mozilla Public License 2.0", 15 | "dependencies": { 16 | "nan": "^2.17.0" 17 | }, 18 | "devDependencies": { 19 | "@release-it-plugins/lerna-changelog": "^6.0.0", 20 | "release-it": "^19.0.2", 21 | "tree-sitter-cli": "0.20.7" 22 | }, 23 | "files": [ 24 | "README.md", 25 | "LICENSE", 26 | "tree-sitter-twig.wasm" 27 | ], 28 | "tree-sitter": [ 29 | { 30 | "scope": "twig", 31 | "file-types": [ 32 | "twig", 33 | "html.twig" 34 | ], 35 | "highlights": [ 36 | "queries/highlights.scm" 37 | ], 38 | "injections": "queries/injections.scm", 39 | "injection-regex": "twig" 40 | } 41 | ], 42 | "release-it": { 43 | "hooks": { 44 | "before:release": "npm run build-wasm" 45 | }, 46 | "plugins": { 47 | "@release-it-plugins/lerna-changelog": { 48 | "infile": "CHANGELOG.md", 49 | "launchEditor": false 50 | } 51 | }, 52 | "git": { 53 | "tagName": "v${version}" 54 | }, 55 | "github": { 56 | "release": true, 57 | "tokenRef": "GITHUB_AUTH" 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /vscode-extension/README.md: -------------------------------------------------------------------------------- 1 | # Shopware Language Server 2 | 3 | A Language Server Protocol (LSP) implementation for Shopware development. 4 | 5 | ## Features 6 | 7 | ### Symfony Service Support 8 | - Service ID completion in PHP, XML, and YAML files 9 | - Navigation to service definitions from PHP, XML, and YAML 10 | - Service code lens in PHP files showing service usage 11 | - Parameter reference completion and navigation in XML files 12 | - Service tag completion in XML files 13 | - Service class completion in XML and YAML files 14 | - Tag-based service lookup and navigation 15 | - YAML service configuration support for class completion and service references 16 | 17 | ### Twig Template Support 18 | - Template path completion in Twig files (`extends`, `include`, `sw_extends`, `sw_include` tags) 19 | - Template path completion in PHP files (`renderStorefront` method calls) 20 | - Go-to-definition for template paths in Twig and PHP files 21 | - Twig block indexing and tracking 22 | - Support for Shopware-specific Twig extensions and tags 23 | 24 | ### Snippet Support 25 | - Snippet indexing and validation in Twig files 26 | - Snippet completion in Twig files 27 | - Diagnostics for missing snippets in Twig templates 28 | - Go-to-definition for snippet keys 29 | 30 | ### Route Support 31 | - Route name completion in PHP (`redirectToRoute` method) and Twig files (`seoUrl`, `url`, `path` functions) 32 | - Go-to-definition for route names 33 | - Route parameter completion 34 | 35 | ### Feature Flag Support 36 | - Feature flag indexing and validation 37 | - Go-to-definition for feature flags 38 | 39 | ### Diagnostics 40 | - Snippet validation in Twig templates 41 | -------------------------------------------------------------------------------- /internal/lsp/completion/feature_completion.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/shopware/shopware-lsp/internal/feature" 7 | "github.com/shopware/shopware-lsp/internal/lsp" 8 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 9 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 10 | ) 11 | 12 | type FeatureCompletionProvider struct { 13 | featureIndex *feature.FeatureIndexer 14 | } 15 | 16 | func NewFeatureCompletionProvider(lspServer *lsp.Server) *FeatureCompletionProvider { 17 | featureIndexer, _ := lspServer.GetIndexer("feature.indexer") 18 | return &FeatureCompletionProvider{ 19 | featureIndex: featureIndexer.(*feature.FeatureIndexer), 20 | } 21 | } 22 | 23 | func (p *FeatureCompletionProvider) GetCompletions(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 24 | if params.Node == nil { 25 | return nil 26 | } 27 | 28 | if treesitterhelper.TwigStringInFunctionPattern("feature").Matches(params.Node, params.DocumentContent) || treesitterhelper.IsStaticPHPMethodCall("Feature", "isActive").Matches(params.Node, params.DocumentContent) || treesitterhelper.IsSCSSFunctionPattern("feature").Matches(params.Node, params.DocumentContent) { 29 | completionItems := []protocol.CompletionItem{} 30 | features, _ := p.featureIndex.GetAllFeatures() 31 | for _, feature := range features { 32 | completionItems = append(completionItems, protocol.CompletionItem{ 33 | Label: feature.Name, 34 | Kind: int(protocol.FunctionCompletion), 35 | }) 36 | } 37 | 38 | return completionItems 39 | } 40 | 41 | return nil 42 | } 43 | 44 | func (p *FeatureCompletionProvider) GetTriggerCharacters() []string { 45 | return []string{} 46 | } 47 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Go Tests 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | paths: 7 | - '**.go' 8 | - 'go.mod' 9 | - 'go.sum' 10 | - '.github/workflows/tests.yml' 11 | pull_request: 12 | branches: [ main ] 13 | paths: 14 | - '**.go' 15 | - 'go.mod' 16 | - 'go.sum' 17 | - '.github/workflows/tests.yml' 18 | 19 | jobs: 20 | test: 21 | name: Test 22 | runs-on: ubuntu-latest 23 | 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v5 27 | 28 | - name: Set up Go 29 | uses: actions/setup-go@v6 30 | with: 31 | go-version: '1.25' 32 | cache: true 33 | 34 | - name: Install dependencies 35 | run: go mod download 36 | 37 | - name: Run tests 38 | run: go test -v ./... 39 | 40 | - name: Run race condition tests 41 | run: go test -race ./internal/... 42 | 43 | lint: 44 | name: Lint 45 | runs-on: ubuntu-latest 46 | steps: 47 | - name: Checkout code 48 | uses: actions/checkout@v5 49 | 50 | - name: Set up Go 51 | uses: actions/setup-go@v6 52 | with: 53 | go-version: '1.25' 54 | cache: true 55 | 56 | - name: Run linting 57 | uses: golangci/golangci-lint-action@v9 58 | with: 59 | version: latest 60 | args: --timeout=5m 61 | 62 | build: 63 | name: Build 64 | runs-on: ubuntu-latest 65 | needs: [test, lint] 66 | steps: 67 | - name: Checkout code 68 | uses: actions/checkout@v5 69 | 70 | - name: Set up Go 71 | uses: actions/setup-go@v6 72 | with: 73 | go-version: '1.25' 74 | cache: true 75 | 76 | - name: Build LSP server 77 | run: go build -v ./... 78 | -------------------------------------------------------------------------------- /internal/lsp/protocol/hover.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import tree_sitter "github.com/tree-sitter/go-tree-sitter" 4 | 5 | // HoverParams represents the parameters for a hover request 6 | type HoverParams struct { 7 | TextDocument struct { 8 | URI string `json:"uri"` 9 | } `json:"textDocument"` 10 | Position struct { 11 | Line int `json:"line"` 12 | Character int `json:"character"` 13 | } `json:"position"` 14 | WorkDoneToken interface{} `json:"workDoneToken,omitempty"` 15 | 16 | // Custom fields for internal use (not part of LSP spec) 17 | // These fields are used to pass document content to hover providers 18 | DocumentContent []byte `json:"-"` 19 | Node *tree_sitter.Node `json:"-"` 20 | } 21 | 22 | // Hover represents the result of a hover request 23 | type Hover struct { 24 | // The hover's content 25 | Contents MarkupContent `json:"contents"` 26 | 27 | // An optional range inside the text document that is used to 28 | // visualize the hover, e.g. by changing the background color 29 | Range *Range `json:"range,omitempty"` 30 | } 31 | 32 | // MarkupContent represents a string value which content is interpreted based on its kind flag 33 | type MarkupContent struct { 34 | // The type of the Markup 35 | Kind MarkupKind `json:"kind"` 36 | 37 | // The content itself 38 | Value string `json:"value"` 39 | } 40 | 41 | // MarkupKind describes the content type that a client supports in various 42 | // result literals like `Hover`, `ParameterInfo` or `CompletionItem` 43 | type MarkupKind string 44 | 45 | const ( 46 | // PlainText plain text is supported as a content format 47 | PlainText MarkupKind = "plaintext" 48 | 49 | // Markdown markdown is supported as a content format 50 | Markdown MarkupKind = "markdown" 51 | ) 52 | -------------------------------------------------------------------------------- /internal/lsp/codeaction/twig_codeaction.go: -------------------------------------------------------------------------------- 1 | package codeaction 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | 7 | "github.com/shopware/shopware-lsp/internal/lsp" 8 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 9 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 10 | ) 11 | 12 | type TwigCodeActionProvider struct { 13 | } 14 | 15 | func NewTwigCodeActionProvider(server *lsp.Server) *TwigCodeActionProvider { 16 | return &TwigCodeActionProvider{} 17 | } 18 | 19 | func (p *TwigCodeActionProvider) GetCodeActionKinds() []protocol.CodeActionKind { 20 | return []protocol.CodeActionKind{ 21 | protocol.CodeActionRefactorExtract, 22 | } 23 | } 24 | 25 | func (p *TwigCodeActionProvider) GetCodeActions(ctx context.Context, params *protocol.CodeActionParams) []protocol.CodeAction { 26 | if params.Node == nil { 27 | return nil 28 | } 29 | 30 | if !strings.Contains(params.TextDocument.URI, "Resources/views/storefront") { 31 | return nil 32 | } 33 | 34 | var codeActions []protocol.CodeAction 35 | 36 | if IsBlock().Matches(params.Node, params.DocumentContent) { 37 | textValue := treesitterhelper.GetNodeText(params.Node, params.DocumentContent) 38 | 39 | codeActions = append(codeActions, protocol.CodeAction{ 40 | Title: "Overwrite this block in Extension", 41 | Kind: protocol.CodeActionRefactorExtract, 42 | Command: &protocol.CommandAction{ 43 | Title: "Overwrite Block", 44 | Command: "shopware.twig.extendBlock", 45 | Arguments: []any{params.TextDocument.URI, textValue}, 46 | }, 47 | }) 48 | } 49 | 50 | return codeActions 51 | } 52 | 53 | func IsBlock() treesitterhelper.Pattern { 54 | return treesitterhelper.And( 55 | treesitterhelper.NodeKind("identifier"), 56 | treesitterhelper.Ancestor( 57 | treesitterhelper.NodeKind("block"), 58 | 1, 59 | ), 60 | ) 61 | } 62 | -------------------------------------------------------------------------------- /internal/php/testdata/typeinference_inheritance.php: -------------------------------------------------------------------------------- 1 | id; 20 | } 21 | 22 | public function getDescription(): string 23 | { 24 | return $this->description; 25 | } 26 | 27 | public function getBaseInformation(): array 28 | { 29 | return [ 30 | 'id' => $this->getId(), 31 | 'description' => $this->getDescription() 32 | ]; 33 | } 34 | } 35 | 36 | class Product extends BaseProduct 37 | { 38 | private string $name; 39 | private ?string $sku = null; 40 | 41 | public function getName(): string 42 | { 43 | return $this->name; 44 | } 45 | 46 | public function setName(string $name): self 47 | { 48 | $this->name = $name; 49 | return $this; 50 | } 51 | 52 | public function getPrice(): float 53 | { 54 | return $this->price; 55 | } 56 | 57 | public function getSku(): ?string 58 | { 59 | return $this->sku; 60 | } 61 | 62 | public function getProductData(): array 63 | { 64 | // Test inheritance - calls methods from parent and self 65 | $baseInfo = $this->getBaseInformation(); 66 | $price = $this->getPrice(); 67 | $name = $this->getName(); 68 | $sku = $this->getSku(); 69 | 70 | return array_merge($baseInfo, [ 71 | 'name' => $name, 72 | 'price' => $price, 73 | 'sku' => $sku 74 | ]); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/src/scanner.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | 5 | enum TokenType { 6 | CONTENT, 7 | COMMENT 8 | }; 9 | 10 | void *tree_sitter_twig_external_scanner_create() { return NULL; } 11 | void tree_sitter_twig_external_scanner_destroy(void *p) {} 12 | void tree_sitter_twig_external_scanner_reset(void *p) {} 13 | unsigned tree_sitter_twig_external_scanner_serialize(void *p, char *buffer) { return 0; } 14 | void tree_sitter_twig_external_scanner_deserialize(void *p, const char *b, unsigned n) {} 15 | 16 | static void advance(TSLexer *lexer) { lexer->advance(lexer, false); } 17 | 18 | bool tree_sitter_twig_external_scanner_scan(void *payload, TSLexer *lexer, const bool *valid_symbols) { 19 | // Eat whitespace 20 | while (iswspace(lexer->lookahead)) { 21 | lexer->advance(lexer, true); 22 | } 23 | 24 | // CONTENT 25 | bool has_content = false; 26 | 27 | while (lexer->lookahead) { 28 | if(lexer->lookahead == '{') { 29 | advance(lexer); 30 | 31 | if(lexer->lookahead == '{' || 32 | lexer->lookahead == '%' || 33 | lexer->lookahead == '#') { 34 | break; 35 | } 36 | } else { 37 | advance(lexer); 38 | } 39 | 40 | lexer->mark_end(lexer); 41 | has_content = true; 42 | } 43 | 44 | if (has_content) { 45 | lexer->result_symbol = CONTENT; 46 | return true; 47 | } 48 | 49 | // COMMENT 50 | if (lexer->lookahead == '#') { 51 | advance(lexer); 52 | 53 | while (lexer->lookahead) { 54 | lexer->mark_end(lexer); 55 | 56 | if(lexer->lookahead == '#') { 57 | advance(lexer); 58 | 59 | if(lexer->lookahead == '}') { 60 | lexer->result_symbol = COMMENT; 61 | advance(lexer); 62 | lexer->mark_end(lexer); 63 | return true; 64 | } 65 | } 66 | 67 | advance(lexer); 68 | } 69 | } 70 | 71 | return false; 72 | } 73 | -------------------------------------------------------------------------------- /internal/theme/theme_indexer_test.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | "github.com/stretchr/testify/require" 9 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 10 | tree_sitter_json "github.com/tree-sitter/tree-sitter-json/bindings/go" 11 | ) 12 | 13 | func TestThemeConfigIndexer(t *testing.T) { 14 | tempDir := t.TempDir() 15 | 16 | // Create a new indexer 17 | indexer, err := NewThemeConfigIndexer(tempDir) 18 | require.NoError(t, err) 19 | defer func() { _ = indexer.Close() }() 20 | 21 | // Load test theme.json file 22 | bytes, err := os.ReadFile("testdata/theme.json") 23 | require.NoError(t, err) 24 | 25 | // Create parser 26 | parser := tree_sitter.NewParser() 27 | require.NoError(t, parser.SetLanguage(tree_sitter.NewLanguage(tree_sitter_json.Language()))) 28 | 29 | // Parse file 30 | tree := parser.Parse(bytes, nil) 31 | require.NotNil(t, tree) 32 | defer tree.Close() 33 | 34 | // Index the file 35 | filePath := "testdata/theme.json" 36 | err = indexer.Index(filePath, tree.RootNode(), bytes) 37 | require.NoError(t, err) 38 | 39 | // Test GetThemeConfigFields 40 | keys, err := indexer.GetThemeConfigFields() 41 | require.NoError(t, err) 42 | assert.NotEmpty(t, keys) 43 | 44 | // Test GetThemeConfigField for a specific key 45 | fields, err := indexer.GetThemeConfigField("sw-color-brand-primary") 46 | require.NoError(t, err) 47 | assert.NotEmpty(t, fields) 48 | assert.Equal(t, "Primary colour", fields[0].Label["en-GB"]) 49 | assert.Equal(t, "color", fields[0].Type) 50 | 51 | // Test GetAllThemeConfigFields 52 | allFields, err := indexer.GetAllThemeConfigFields() 53 | require.NoError(t, err) 54 | assert.NotEmpty(t, allFields) 55 | 56 | // Test removing a file 57 | err = indexer.RemovedFiles([]string{filePath}) 58 | require.NoError(t, err) 59 | 60 | // Verify the file was removed 61 | emptyKeys, err := indexer.GetThemeConfigFields() 62 | require.NoError(t, err) 63 | assert.Empty(t, emptyKeys) 64 | } 65 | -------------------------------------------------------------------------------- /internal/php/interface_test.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestInterfaceIndexing(t *testing.T) { 11 | // Create a new PHP index with a temporary directory following the guidelines 12 | idx, err := NewPHPIndex(t.TempDir()) 13 | assert.NoError(t, err) 14 | 15 | // Use the test file path for interfaces 16 | path := filepath.Join("testdata", "interface.php") 17 | classes := idx.GetClassesOfFile(path) 18 | 19 | // Check that the interface was indexed 20 | assert.Contains(t, classes, "App\\Interfaces\\CustomInterface", "Interface should be indexed") 21 | 22 | customInterface := classes["App\\Interfaces\\CustomInterface"] 23 | 24 | // Check that the interface is correctly identified 25 | assert.True(t, customInterface.IsInterface, "CustomInterface should be identified as an interface") 26 | 27 | // Check that extended interfaces are correctly identified 28 | // Note: Current namespace resolution results in local namespace prefixing for Traversable 29 | assert.Contains(t, customInterface.Interfaces, "App\\Interfaces\\Traversable", "Interface should extend Traversable") 30 | assert.Contains(t, customInterface.Interfaces, "LoggerInterface", "Interface should extend LoggerInterface") 31 | assert.Len(t, customInterface.Interfaces, 2, "Interface should extend exactly 2 interfaces") 32 | 33 | // Verify methods are correctly indexed 34 | assert.Len(t, customInterface.Methods, 2, "Interface should have 2 methods") 35 | assert.Contains(t, customInterface.Methods, "getCustomValue", "Interface should have getCustomValue method") 36 | assert.Contains(t, customInterface.Methods, "setCustomValue", "Interface should have setCustomValue method") 37 | 38 | // Verify properties - interfaces shouldn't have properties 39 | assert.Len(t, customInterface.Properties, 0, "Interface should have 0 properties") 40 | 41 | // Verify parent is empty for interfaces 42 | assert.Empty(t, customInterface.Parent, "Interface should not have a parent class") 43 | } 44 | -------------------------------------------------------------------------------- /internal/theme/parser_test.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 9 | tree_sitter_json "github.com/tree-sitter/tree-sitter-json/bindings/go" 10 | ) 11 | 12 | func TestParseThemeConfig(t *testing.T) { 13 | bytes, err := os.ReadFile("testdata/theme.json") 14 | assert.NoError(t, err) 15 | 16 | parser := tree_sitter.NewParser() 17 | assert.NoError(t, parser.SetLanguage(tree_sitter.NewLanguage(tree_sitter_json.Language()))) 18 | 19 | tree := parser.Parse(bytes, nil) 20 | if tree == nil { 21 | t.Fatalf("Failed to parse JSON") 22 | } 23 | defer tree.Close() 24 | 25 | filePath := "testdata/theme.json" 26 | fields, err := ParseThemeConfig(tree.RootNode(), bytes, filePath) 27 | assert.NoError(t, err) 28 | 29 | // Verify we got fields 30 | assert.NotEmpty(t, fields) 31 | 32 | // A map to make field searching easier for tests 33 | fieldsMap := make(map[string]ThemeConfigField) 34 | for _, field := range fields { 35 | fieldsMap[field.Key] = field 36 | } 37 | 38 | // Check that important fields exist 39 | assert.Contains(t, fieldsMap, "sw-color-brand-primary") 40 | assert.Contains(t, fieldsMap, "sw-color-success") 41 | assert.Contains(t, fieldsMap, "sw-logo-desktop") 42 | 43 | // Check a specific field 44 | primaryColorField := fieldsMap["sw-color-brand-primary"] 45 | assert.Equal(t, "Primary colour", primaryColorField.Label["en-GB"]) 46 | assert.Equal(t, "color", primaryColorField.Type) 47 | assert.Equal(t, "#0042a0", primaryColorField.Value) 48 | assert.True(t, primaryColorField.Editable) 49 | assert.Equal(t, "themeColors", primaryColorField.Block) 50 | assert.Equal(t, 100, primaryColorField.Order) 51 | 52 | // Check the Path and Line fields 53 | assert.Equal(t, filePath, primaryColorField.Path) 54 | assert.Greater(t, primaryColorField.Line, 0) // Line should be greater than 0 55 | 56 | // Verify we have the expected number of fields 57 | expectedFieldCount := 20 // Based on the theme.json file 58 | assert.Len(t, fields, expectedFieldCount) 59 | } 60 | -------------------------------------------------------------------------------- /internal/twig/parser_test.go: -------------------------------------------------------------------------------- 1 | package twig 2 | 3 | import ( 4 | "testing" 5 | 6 | tree_sitter_twig "github.com/shopware/shopware-lsp/internal/tree_sitter_grammars/twig/bindings/go" 7 | "github.com/stretchr/testify/assert" 8 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 9 | ) 10 | 11 | func TestTwigParse(t *testing.T) { 12 | parser := tree_sitter.NewParser() 13 | 14 | assert.NoError(t, parser.SetLanguage(tree_sitter.NewLanguage(tree_sitter_twig.Language()))) 15 | 16 | content := []byte(`{% block foo %}{% endblock %}`) 17 | 18 | tree := parser.Parse(content, nil) 19 | defer tree.Close() 20 | 21 | file, err := ParseTwig("test", tree.RootNode(), content) 22 | assert.NoError(t, err) 23 | 24 | assert.Equal(t, "test", file.Path) 25 | assert.Equal(t, map[string]TwigBlock{"foo": {Name: "foo", Line: 1}}, file.Blocks) 26 | } 27 | 28 | func TestTwigParseSwExtends(t *testing.T) { 29 | parser := tree_sitter.NewParser() 30 | 31 | assert.NoError(t, parser.SetLanguage(tree_sitter.NewLanguage(tree_sitter_twig.Language()))) 32 | 33 | content := []byte(`{% sw_extends '@Storefront/storefront/base.html.twig' %}`) 34 | 35 | tree := parser.Parse(content, nil) 36 | defer tree.Close() 37 | 38 | file, err := ParseTwig("test", tree.RootNode(), content) 39 | assert.NoError(t, err) 40 | 41 | assert.Equal(t, "test", file.Path) 42 | assert.Equal(t, "@Storefront/storefront/base.html.twig", file.ExtendsFile) 43 | } 44 | 45 | func TestNestedBlock(t *testing.T) { 46 | tpl := ` 47 | {% block a %} 48 | {% block b %} 49 | {% block c %} 50 | {% endblock %} 51 | {% endblock %} 52 | {% endblock %} 53 | ` 54 | 55 | parser := tree_sitter.NewParser() 56 | 57 | assert.NoError(t, parser.SetLanguage(tree_sitter.NewLanguage(tree_sitter_twig.Language()))) 58 | 59 | tree := parser.Parse([]byte(tpl), nil) 60 | defer tree.Close() 61 | 62 | file, err := ParseTwig("test", tree.RootNode(), []byte(tpl)) 63 | assert.NoError(t, err) 64 | 65 | assert.Equal(t, "test", file.Path) 66 | assert.Equal(t, map[string]TwigBlock{"a": {Name: "a", Line: 2}, "b": {Name: "b", Line: 3}, "c": {Name: "c", Line: 4}}, file.Blocks) 67 | } 68 | -------------------------------------------------------------------------------- /internal/feature/feature_test.go: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | "github.com/stretchr/testify/require" 10 | 11 | tree_sitter_yaml "github.com/tree-sitter-grammars/tree-sitter-yaml/bindings/go" 12 | sitter "github.com/tree-sitter/go-tree-sitter" 13 | ) 14 | 15 | func TestParseFeatureFile(t *testing.T) { 16 | // Read the test file 17 | filePath := filepath.Join("testdata", "feature.yaml") 18 | content, err := os.ReadFile(filePath) 19 | require.NoError(t, err, "Reading test file should not fail") 20 | 21 | // Parse the YAML file with tree-sitter 22 | parser := sitter.NewParser() 23 | err = parser.SetLanguage(sitter.NewLanguage(tree_sitter_yaml.Language())) 24 | require.NoError(t, err, "Setting language should not fail") 25 | 26 | tree := parser.Parse(content, nil) 27 | require.NotNil(t, tree, "Parsing YAML should not fail") 28 | 29 | // Parse the features from the file 30 | features, err := ParseFeatureFile(tree.RootNode(), content, filePath) 31 | require.NoError(t, err, "Parsing feature file should not fail") 32 | require.Len(t, features, 8, "Should find 8 features in the test file") 33 | 34 | // Verify the expected features are present 35 | expectedFeatures := map[string]int{ 36 | "v6.5.0.0": 4, 37 | "v6.6.0.0": 8, 38 | "v6.7.0.0": 12, 39 | "v6.8.0.0": 16, 40 | "DISABLE_VUE_COMPAT": 20, 41 | "ACCESSIBILITY_TWEAKS": 24, 42 | "TELEMETRY_METRICS": 29, 43 | "FLOW_EXECUTION_AFTER_BUSINESS_PROCESS": 34, 44 | } 45 | 46 | for _, feature := range features { 47 | expectedLine, ok := expectedFeatures[feature.Name] 48 | assert.True(t, ok, "Feature %s should be in the expected list", feature.Name) 49 | assert.Equal(t, expectedLine, feature.Line, "Feature %s should be at line %d", feature.Name, expectedLine) 50 | assert.Equal(t, filePath, feature.File, "Feature %s should have the correct file path", feature.Name) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/snippet/snippet_indexer.go: -------------------------------------------------------------------------------- 1 | package snippet 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/shopware/shopware-lsp/internal/indexer" 8 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 9 | ) 10 | 11 | type SnippetIndexer struct { 12 | frontendIndex *indexer.DataIndexer[Snippet] 13 | } 14 | 15 | func NewSnippetIndexer(configDir string) (*SnippetIndexer, error) { 16 | frontendIndexer, err := indexer.NewDataIndexer[Snippet](filepath.Join(configDir, "frontend_snippet.db")) 17 | 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | return &SnippetIndexer{ 23 | frontendIndex: frontendIndexer, 24 | }, nil 25 | } 26 | 27 | func (s *SnippetIndexer) ID() string { 28 | return "snippet.indexer" 29 | } 30 | 31 | func (s *SnippetIndexer) Index(path string, node *tree_sitter.Node, fileContent []byte) error { 32 | if !strings.Contains(path, "/Resources/snippet/") || strings.Contains(path, "/_fixtures/") { 33 | return nil 34 | } 35 | 36 | snippets, err := parseSnippetFile(node, fileContent, path) 37 | 38 | if err != nil { 39 | return err 40 | } 41 | 42 | batchSave := make(map[string]map[string]Snippet) 43 | 44 | for snippetKey, snippet := range snippets { 45 | if _, ok := batchSave[snippet.File]; !ok { 46 | batchSave[snippet.File] = make(map[string]Snippet) 47 | } 48 | batchSave[snippet.File][snippetKey] = snippet 49 | } 50 | 51 | return s.frontendIndex.BatchSaveItems(batchSave) 52 | } 53 | 54 | func (s *SnippetIndexer) RemovedFiles(paths []string) error { 55 | return s.frontendIndex.BatchDeleteByFilePaths(paths) 56 | } 57 | 58 | func (s *SnippetIndexer) Close() error { 59 | return s.frontendIndex.Close() 60 | } 61 | 62 | func (s *SnippetIndexer) Clear() error { 63 | return s.frontendIndex.Clear() 64 | } 65 | 66 | func (s *SnippetIndexer) GetFrontendSnippets() ([]string, error) { 67 | return s.frontendIndex.GetAllKeys() 68 | } 69 | 70 | func (s *SnippetIndexer) GetFrontendSnippet(key string) ([]Snippet, error) { 71 | return s.frontendIndex.GetValues(key) 72 | } 73 | 74 | func (s *SnippetIndexer) GetAllSnippets() ([]Snippet, error) { 75 | return s.frontendIndex.GetAllValues() 76 | } 77 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/twig/bindings/rust/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate provides twig language support for the [tree-sitter][] parsing library. 2 | //! 3 | //! Typically, you will use the [language][language func] function to add this language to a 4 | //! tree-sitter [Parser][], and then use the parser to parse some code: 5 | //! 6 | //! ``` 7 | //! let code = ""; 8 | //! let mut parser = tree_sitter::Parser::new(); 9 | //! parser.set_language(tree_sitter_twig::language()).expect("Error loading twig grammar"); 10 | //! let tree = parser.parse(code, None).unwrap(); 11 | //! ``` 12 | //! 13 | //! [Language]: https://docs.rs/tree-sitter/*/tree_sitter/struct.Language.html 14 | //! [language func]: fn.language.html 15 | //! [Parser]: https://docs.rs/tree-sitter/*/tree_sitter/struct.Parser.html 16 | //! [tree-sitter]: https://tree-sitter.github.io/ 17 | 18 | use tree_sitter::Language; 19 | 20 | extern "C" { 21 | fn tree_sitter_twig() -> Language; 22 | } 23 | 24 | /// Get the tree-sitter [Language][] for this grammar. 25 | /// 26 | /// [Language]: https://docs.rs/tree-sitter/*/tree_sitter/struct.Language.html 27 | pub fn language() -> Language { 28 | unsafe { tree_sitter_twig() } 29 | } 30 | 31 | /// The content of the [`node-types.json`][] file for this grammar. 32 | /// 33 | /// [`node-types.json`]: https://tree-sitter.github.io/tree-sitter/using-parsers#static-node-types 34 | pub const NODE_TYPES: &'static str = include_str!("../../src/node-types.json"); 35 | 36 | // Uncomment these to include any queries that this grammar contains 37 | 38 | // pub const HIGHLIGHTS_QUERY: &'static str = include_str!("../../queries/highlights.scm"); 39 | // pub const INJECTIONS_QUERY: &'static str = include_str!("../../queries/injections.scm"); 40 | // pub const LOCALS_QUERY: &'static str = include_str!("../../queries/locals.scm"); 41 | // pub const TAGS_QUERY: &'static str = include_str!("../../queries/tags.scm"); 42 | 43 | #[cfg(test)] 44 | mod tests { 45 | #[test] 46 | fn test_can_load_grammar() { 47 | let mut parser = tree_sitter::Parser::new(); 48 | parser 49 | .set_language(super::language()) 50 | .expect("Error loading twig language"); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/php/inheritance_test.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "path/filepath" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestClassInheritance(t *testing.T) { 11 | // Create a new PHP index with a temporary directory following the guidelines 12 | idx, err := NewPHPIndex(t.TempDir()) 13 | assert.NoError(t, err) 14 | 15 | // Use the test file path for inheritance 16 | path := filepath.Join("testdata", "inheritance.php") 17 | classes := idx.GetClassesOfFile(path) 18 | 19 | // Check that the class was indexed 20 | assert.Contains(t, classes, "App\\Entity\\Product", "Class should be indexed") 21 | 22 | product := classes["App\\Entity\\Product"] 23 | 24 | // Check that parent is correctly identified 25 | assert.Equal(t, "App\\BaseClass", product.Parent, "Class should extend App\\BaseClass") 26 | 27 | // Check that interfaces are correctly identified 28 | // NOTE: Currently the AliasResolver implementation treats global interfaces 29 | // imported with 'use' statements as being in the current namespace. 30 | // This can be improved in the future to properly recognize global PHP interfaces. 31 | assert.Contains(t, product.Interfaces, "App\\Entity\\Traversable", "Class should implement Traversable") 32 | assert.Contains(t, product.Interfaces, "App\\Entity\\Countable", "Class should implement Countable") 33 | assert.Len(t, product.Interfaces, 2, "Class should implement exactly 2 interfaces") 34 | 35 | // Verify other class aspects are still correctly indexed 36 | assert.Len(t, product.Methods, 4, "Class should have 4 methods") 37 | assert.Contains(t, product.Methods, "getId", "Class should have getId method") 38 | assert.Contains(t, product.Methods, "getName", "Class should have getName method") 39 | assert.Contains(t, product.Methods, "setName", "Class should have setName method") 40 | assert.Contains(t, product.Methods, "count", "Class should have count method from Countable interface") 41 | 42 | assert.Len(t, product.Properties, 2, "Class should have 2 properties") 43 | assert.Contains(t, product.Properties, "id", "Class should have id property") 44 | assert.Contains(t, product.Properties, "name", "Class should have name property") 45 | } 46 | -------------------------------------------------------------------------------- /internal/symfony/route_usage_indexer.go: -------------------------------------------------------------------------------- 1 | package symfony 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/shopware/shopware-lsp/internal/indexer" 7 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 8 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 9 | ) 10 | 11 | type RouteUsage struct { 12 | Name string 13 | File string 14 | Line int 15 | } 16 | 17 | type RouteUsageIndexer struct { 18 | dataIndexer *indexer.DataIndexer[RouteUsage] 19 | } 20 | 21 | func NewRouteUsageIndexer(configDir string) (*RouteUsageIndexer, error) { 22 | dataIndexer, err := indexer.NewDataIndexer[RouteUsage](filepath.Join(configDir, "route_usage.db")) 23 | if err != nil { 24 | return nil, err 25 | } 26 | return &RouteUsageIndexer{ 27 | dataIndexer: dataIndexer, 28 | }, nil 29 | } 30 | 31 | func (idx *RouteUsageIndexer) ID() string { 32 | return "symfony.route_usage" 33 | } 34 | 35 | func (idx *RouteUsageIndexer) Index(path string, node *tree_sitter.Node, fileContent []byte) error { 36 | matches := treesitterhelper.FindAll(node, treesitterhelper.IsPHPThisMethodCall("redirectToRoute"), fileContent) 37 | matches = append(matches, treesitterhelper.FindAll(node, treesitterhelper.TwigStringInFunctionPattern("seoUrl", "url", "path"), fileContent)...) 38 | 39 | batchSave := make(map[string]map[string]RouteUsage) 40 | 41 | for _, match := range matches { 42 | name := treesitterhelper.GetNodeText(match, fileContent) 43 | if _, ok := batchSave[path]; !ok { 44 | batchSave[path] = make(map[string]RouteUsage) 45 | } 46 | batchSave[path][name] = RouteUsage{ 47 | Name: name, 48 | File: path, 49 | Line: int(match.Range().StartPoint.Row) + 1, 50 | } 51 | } 52 | 53 | return idx.dataIndexer.BatchSaveItems(batchSave) 54 | } 55 | 56 | func (idx *RouteUsageIndexer) RemovedFiles(paths []string) error { 57 | return idx.dataIndexer.BatchDeleteByFilePaths(paths) 58 | } 59 | 60 | func (idx *RouteUsageIndexer) Clear() error { 61 | return idx.dataIndexer.Clear() 62 | } 63 | 64 | func (idx *RouteUsageIndexer) Close() error { 65 | return idx.dataIndexer.Close() 66 | } 67 | 68 | func (idx *RouteUsageIndexer) GetRoute(name string) ([]RouteUsage, error) { 69 | return idx.dataIndexer.GetValues(name) 70 | } 71 | -------------------------------------------------------------------------------- /internal/lsp/reference/route_reference.go: -------------------------------------------------------------------------------- 1 | package reference 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | 8 | "github.com/shopware/shopware-lsp/internal/lsp" 9 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 10 | "github.com/shopware/shopware-lsp/internal/symfony" 11 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 12 | ) 13 | 14 | type RouteReferenceProvider struct { 15 | routeIndex *symfony.RouteIndexer 16 | routeUsageIndex *symfony.RouteUsageIndexer 17 | } 18 | 19 | func NewRouteReferenceProvider(lspServer *lsp.Server) *RouteReferenceProvider { 20 | routeIndex, _ := lspServer.GetIndexer("symfony.route") 21 | routeUsageIndex, _ := lspServer.GetIndexer("symfony.route_usage") 22 | return &RouteReferenceProvider{ 23 | routeIndex: routeIndex.(*symfony.RouteIndexer), 24 | routeUsageIndex: routeUsageIndex.(*symfony.RouteUsageIndexer), 25 | } 26 | } 27 | 28 | func (r *RouteReferenceProvider) GetReferences(ctx context.Context, params *protocol.ReferenceParams) []protocol.Location { 29 | if params.Node == nil { 30 | return nil 31 | } 32 | 33 | switch filepath.Ext(params.TextDocument.URI) { 34 | case ".php": 35 | return r.getReferencesForPHP(ctx, params) 36 | default: 37 | return nil 38 | } 39 | } 40 | 41 | func (r *RouteReferenceProvider) getReferencesForPHP(ctx context.Context, params *protocol.ReferenceParams) []protocol.Location { 42 | methodFQCN := treesitterhelper.GetMethodFQCN(params.Node, []byte(params.DocumentContent)) 43 | 44 | if methodFQCN != "" { 45 | routes, _ := r.routeIndex.GetRoutes() 46 | 47 | route := routes.GetByController(methodFQCN) 48 | 49 | if route != nil { 50 | locations, _ := r.routeUsageIndex.GetRoute(route.Name) 51 | 52 | var result []protocol.Location 53 | 54 | for _, location := range locations { 55 | result = append(result, protocol.Location{ 56 | URI: fmt.Sprintf("file://%s", location.File), 57 | Range: protocol.Range{ 58 | Start: protocol.Position{ 59 | Line: location.Line - 1, 60 | Character: 0, 61 | }, 62 | End: protocol.Position{ 63 | Line: location.Line - 1, 64 | Character: 0, 65 | }, 66 | }, 67 | }) 68 | } 69 | 70 | return result 71 | } 72 | } 73 | 74 | return nil 75 | } 76 | -------------------------------------------------------------------------------- /internal/indexer/treesitter.go: -------------------------------------------------------------------------------- 1 | package indexer 2 | 3 | import ( 4 | tree_sitter_twig "github.com/shopware/shopware-lsp/internal/tree_sitter_grammars/twig/bindings/go" 5 | tree_sitter_scss "github.com/tree-sitter-grammars/tree-sitter-scss/bindings/go" 6 | tree_sitter_xml "github.com/tree-sitter-grammars/tree-sitter-xml/bindings/go" 7 | tree_sitter_yaml "github.com/tree-sitter-grammars/tree-sitter-yaml/bindings/go" 8 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 9 | tree_sitter_json "github.com/tree-sitter/tree-sitter-json/bindings/go" 10 | tree_sitter_php "github.com/tree-sitter/tree-sitter-php/bindings/go" 11 | ) 12 | 13 | var scannedFileTypes = []string{ 14 | ".php", 15 | ".xml", 16 | ".yaml", 17 | ".yml", 18 | ".twig", 19 | ".json", 20 | ".scss", 21 | } 22 | 23 | func CreateTreesitterParsers() map[string]*tree_sitter.Parser { 24 | parsers := make(map[string]*tree_sitter.Parser) 25 | 26 | parsers[".php"] = tree_sitter.NewParser() 27 | if err := parsers[".php"].SetLanguage(tree_sitter.NewLanguage(tree_sitter_php.LanguagePHP())); err != nil { 28 | panic(err) 29 | } 30 | 31 | parsers[".xml"] = tree_sitter.NewParser() 32 | if err := parsers[".xml"].SetLanguage(tree_sitter.NewLanguage(tree_sitter_xml.LanguageXML())); err != nil { 33 | panic(err) 34 | } 35 | 36 | parsers[".twig"] = tree_sitter.NewParser() 37 | if err := parsers[".twig"].SetLanguage(tree_sitter.NewLanguage(tree_sitter_twig.Language())); err != nil { 38 | panic(err) 39 | } 40 | 41 | parsers[".yaml"] = tree_sitter.NewParser() 42 | if err := parsers[".yaml"].SetLanguage(tree_sitter.NewLanguage(tree_sitter_yaml.Language())); err != nil { 43 | panic(err) 44 | } 45 | 46 | parsers[".yml"] = tree_sitter.NewParser() 47 | if err := parsers[".yml"].SetLanguage(tree_sitter.NewLanguage(tree_sitter_yaml.Language())); err != nil { 48 | panic(err) 49 | } 50 | 51 | parsers[".scss"] = tree_sitter.NewParser() 52 | if err := parsers[".scss"].SetLanguage(tree_sitter.NewLanguage(tree_sitter_scss.Language())); err != nil { 53 | panic(err) 54 | } 55 | 56 | parsers[".json"] = tree_sitter.NewParser() 57 | if err := parsers[".json"].SetLanguage(tree_sitter.NewLanguage(tree_sitter_json.Language())); err != nil { 58 | panic(err) 59 | } 60 | 61 | return parsers 62 | } 63 | 64 | func CloseTreesitterParsers(parsers map[string]*tree_sitter.Parser) { 65 | for _, parser := range parsers { 66 | parser.Close() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /internal/symfony/yaml_routes_test.go: -------------------------------------------------------------------------------- 1 | package symfony 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | tree_sitter_yaml "github.com/tree-sitter-grammars/tree-sitter-yaml/bindings/go" 8 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 9 | ) 10 | 11 | func TestParseYAMLRoutes(t *testing.T) { 12 | // Create a temporary test file 13 | yamlContent := `# Routes file 14 | app_homepage: 15 | path: / 16 | controller: App\Controller\DefaultController::index 17 | 18 | app_product: 19 | path: /product/{id} 20 | controller: App\Controller\ProductController::show 21 | methods: [GET] 22 | 23 | app_product_create: 24 | path: /product/create 25 | controller: App\Controller\ProductController::create 26 | methods: [GET, POST] 27 | defaults: 28 | color: blue 29 | ` 30 | 31 | // Create a YAML parser 32 | parser := tree_sitter.NewParser() 33 | if err := parser.SetLanguage(tree_sitter.NewLanguage(tree_sitter_yaml.Language())); err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | // Parse the YAML content 38 | tree := parser.Parse([]byte(yamlContent), nil) 39 | defer tree.Close() 40 | 41 | // Run the parser 42 | routes, err := ParseYAMLRoutes("test.yaml", tree.RootNode(), []byte(yamlContent)) 43 | assert.NoError(t, err) 44 | 45 | // Verify the parsed routes 46 | if assert.Len(t, routes, 3, "Expected 3 routes from tree-sitter YAML parsing") { 47 | // Check the first route 48 | assert.Equal(t, "app_homepage", routes[0].Name) 49 | assert.Equal(t, "/", routes[0].Path) 50 | assert.Equal(t, "App\\Controller\\DefaultController::index", routes[0].Controller) 51 | assert.Equal(t, "test.yaml", routes[0].FilePath) 52 | assert.Greater(t, routes[0].Line, 0) 53 | 54 | // Check the second route 55 | assert.Equal(t, "app_product", routes[1].Name) 56 | assert.Equal(t, "/product/{id}", routes[1].Path) 57 | assert.Equal(t, "App\\Controller\\ProductController::show", routes[1].Controller) 58 | assert.Equal(t, "test.yaml", routes[1].FilePath) 59 | assert.Greater(t, routes[1].Line, 0) 60 | 61 | // Check the third route 62 | assert.Equal(t, "app_product_create", routes[2].Name) 63 | assert.Equal(t, "/product/create", routes[2].Path) 64 | assert.Equal(t, "App\\Controller\\ProductController::create", routes[2].Controller) 65 | assert.Equal(t, "test.yaml", routes[2].FilePath) 66 | assert.Greater(t, routes[2].Line, 0) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /internal/lsp/codelens/php_service_codelens.go: -------------------------------------------------------------------------------- 1 | package codelens 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/shopware/shopware-lsp/internal/lsp" 9 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 10 | "github.com/shopware/shopware-lsp/internal/php" 11 | "github.com/shopware/shopware-lsp/internal/symfony" 12 | ) 13 | 14 | type PHPServiceCodelensProvider struct { 15 | phpIndex *php.PHPIndex 16 | serviceIndex *symfony.ServiceIndex 17 | } 18 | 19 | func NewPHPCodeLensProvider(lsp *lsp.Server) *PHPServiceCodelensProvider { 20 | phpIndex, _ := lsp.GetIndexer("php.index") 21 | serviceIndex, _ := lsp.GetIndexer("symfony.service") 22 | 23 | return &PHPServiceCodelensProvider{ 24 | phpIndex: phpIndex.(*php.PHPIndex), 25 | serviceIndex: serviceIndex.(*symfony.ServiceIndex), 26 | } 27 | } 28 | 29 | func (p *PHPServiceCodelensProvider) GetCodeLenses(ctx context.Context, params *protocol.CodeLensParams) []protocol.CodeLens { 30 | if !strings.HasSuffix(params.TextDocument.URI, ".php") { 31 | return []protocol.CodeLens{} 32 | } 33 | 34 | phpClasses := p.phpIndex.GetClassesOfFile(strings.TrimPrefix(params.TextDocument.URI, "file://")) 35 | 36 | if len(phpClasses) == 0 { 37 | return []protocol.CodeLens{} 38 | } 39 | 40 | var lenses []protocol.CodeLens 41 | 42 | for _, phpClass := range phpClasses { 43 | locations := p.serviceIndex.GetServicesUsageByClassName(phpClass.Name) 44 | 45 | if len(locations) == 0 { 46 | continue 47 | } 48 | 49 | var fileLocations []string 50 | for _, location := range locations { 51 | fileLocations = append(fileLocations, fmt.Sprintf("file://%s#%d", location.Path, location.Line)) 52 | } 53 | 54 | lenses = append(lenses, protocol.CodeLens{ 55 | Command: &protocol.Command{ 56 | Title: "Open Service Definition", 57 | Command: "shopware.openReferences", 58 | Arguments: []any{ 59 | fileLocations, 60 | }, 61 | }, 62 | Range: protocol.Range{ 63 | Start: protocol.Position{ 64 | Line: phpClass.Line - 1, 65 | Character: 0, 66 | }, 67 | End: protocol.Position{ 68 | Line: phpClass.Line - 1, 69 | Character: 0, 70 | }, 71 | }, 72 | }) 73 | } 74 | 75 | return lenses 76 | } 77 | 78 | func (p *PHPServiceCodelensProvider) ResolveCodeLens(ctx context.Context, params *protocol.CodeLens) (*protocol.CodeLens, error) { 79 | return params, nil 80 | } 81 | -------------------------------------------------------------------------------- /internal/feature/feature.go: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import ( 4 | "fmt" 5 | 6 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 7 | ) 8 | 9 | type Feature struct { 10 | Name string 11 | File string 12 | Line int 13 | } 14 | 15 | func ParseFeatureFile(root *tree_sitter.Node, document []byte, filePath string) ([]Feature, error) { 16 | features := []Feature{} 17 | 18 | // Directly traverse the tree to find block_mapping_pair with key "name" 19 | traverseForFeatures(root, document, filePath, &features) 20 | 21 | if len(features) == 0 { 22 | return features, fmt.Errorf("could not find flags node in file: %s", filePath) 23 | } 24 | 25 | return features, nil 26 | } 27 | 28 | func traverseForFeatures(node *tree_sitter.Node, document []byte, filePath string, features *[]Feature) { 29 | // For debugging 30 | if node.Kind() == "block_mapping_pair" { 31 | keyNode := node.ChildByFieldName("key") 32 | if keyNode != nil { 33 | for i := uint(0); i < keyNode.NamedChildCount(); i++ { 34 | child := keyNode.NamedChild(i) 35 | if child.Kind() == "plain_scalar" { 36 | for j := uint(0); j < child.NamedChildCount(); j++ { 37 | textNode := child.NamedChild(j) 38 | if textNode.Kind() == "string_scalar" { 39 | keyText := string(textNode.Utf8Text(document)) 40 | 41 | // If this is a "name" key 42 | if keyText == "name" { 43 | // Look for the value 44 | valueNode := node.ChildByFieldName("value") 45 | if valueNode != nil { 46 | for k := uint(0); k < valueNode.NamedChildCount(); k++ { 47 | valChild := valueNode.NamedChild(k) 48 | if valChild.Kind() == "plain_scalar" { 49 | for l := uint(0); l < valChild.NamedChildCount(); l++ { 50 | nameNode := valChild.NamedChild(l) 51 | if nameNode.Kind() == "string_scalar" { 52 | nameText := string(nameNode.Utf8Text(document)) 53 | *features = append(*features, Feature{ 54 | Name: nameText, 55 | File: filePath, 56 | Line: int(nameNode.Range().StartPoint.Row) + 1, 57 | }) 58 | } 59 | } 60 | } 61 | } 62 | } 63 | } 64 | } 65 | } 66 | } 67 | } 68 | } 69 | } 70 | 71 | // Recursively search all children 72 | for i := uint(0); i < node.NamedChildCount(); i++ { 73 | traverseForFeatures(node.NamedChild(i), document, filePath, features) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /internal/lsp/diagnostics/snippet_diagnostics.go: -------------------------------------------------------------------------------- 1 | package diagnostics 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/shopware/shopware-lsp/internal/lsp" 10 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 11 | "github.com/shopware/shopware-lsp/internal/snippet" 12 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 13 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 14 | ) 15 | 16 | type SnippetDiagnosticsProvider struct { 17 | snippetIndex *snippet.SnippetIndexer 18 | } 19 | 20 | func NewSnippetDiagnosticsProvider(lspServer *lsp.Server) *SnippetDiagnosticsProvider { 21 | snippetIndexer, _ := lspServer.GetIndexer("snippet.indexer") 22 | return &SnippetDiagnosticsProvider{ 23 | snippetIndex: snippetIndexer.(*snippet.SnippetIndexer), 24 | } 25 | } 26 | 27 | func (s *SnippetDiagnosticsProvider) GetDiagnostics(ctx context.Context, uri string, rootNode *tree_sitter.Node, content []byte) ([]protocol.Diagnostic, error) { 28 | switch strings.ToLower(filepath.Ext(uri)) { 29 | case ".twig": 30 | return s.twigDiagnostics(ctx, uri, rootNode, content) 31 | default: 32 | return []protocol.Diagnostic{}, nil 33 | } 34 | } 35 | 36 | func (s *SnippetDiagnosticsProvider) twigDiagnostics(ctx context.Context, uri string, rootNode *tree_sitter.Node, content []byte) ([]protocol.Diagnostic, error) { 37 | matches := treesitterhelper.FindAll(rootNode, treesitterhelper.TwigTransPattern(), content) 38 | 39 | var diagnostics []protocol.Diagnostic 40 | for _, match := range matches { 41 | snippetText := treesitterhelper.GetNodeText(match, content) 42 | 43 | snippets, _ := s.snippetIndex.GetFrontendSnippet(snippetText) 44 | 45 | if len(snippets) == 0 { 46 | diagnostics = append(diagnostics, protocol.Diagnostic{ 47 | Range: protocol.Range{ 48 | Start: protocol.Position{ 49 | Line: int(match.StartPosition().Row), 50 | Character: int(match.StartPosition().Column), 51 | }, 52 | End: protocol.Position{ 53 | Line: int(match.EndPosition().Row), 54 | Character: int(match.EndPosition().Column), 55 | }, 56 | }, 57 | Message: fmt.Sprintf("Snippet %s not found", snippetText), 58 | Source: "shopware", 59 | Severity: protocol.DiagnosticSeverityError, 60 | Code: "frontend.snippet.missing", 61 | Data: map[string]any{ 62 | "snippetText": snippetText, 63 | }, 64 | }) 65 | } 66 | } 67 | 68 | return diagnostics, nil 69 | } 70 | -------------------------------------------------------------------------------- /internal/php/debug_ast.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 8 | tree_sitter_php "github.com/tree-sitter/tree-sitter-php/bindings/go" 9 | ) 10 | 11 | // DebugAST parses a PHP file and prints the AST structure 12 | func DebugAST(filePath string) { 13 | fileContent, err := os.ReadFile(filePath) 14 | if err != nil { 15 | fmt.Printf("Error reading file: %v\n", err) 16 | return 17 | } 18 | 19 | parser := tree_sitter.NewParser() 20 | if err := parser.SetLanguage(tree_sitter.NewLanguage(tree_sitter_php.LanguagePHP())); err != nil { 21 | fmt.Printf("Error setting language: %v\n", err) 22 | return 23 | } 24 | 25 | defer parser.Close() 26 | 27 | tree := parser.Parse(fileContent, nil) 28 | rootNode := tree.RootNode() 29 | 30 | printNodeStructure(rootNode, fileContent, 0) 31 | } 32 | 33 | // printNodeStructure recursively prints the node structure 34 | func printNodeStructure(node *tree_sitter.Node, fileContent []byte, depth int) { 35 | if node == nil { 36 | return 37 | } 38 | 39 | indent := "" 40 | for i := 0; i < depth; i++ { 41 | indent += " " 42 | } 43 | 44 | nodeText := "" 45 | if node.NamedChildCount() == 0 { 46 | nodeText = string(node.Utf8Text(fileContent)) 47 | } 48 | 49 | fmt.Printf("%sNode: %s, Text: %s\n", indent, node.Kind(), nodeText) 50 | 51 | // Print property declarations with more detail 52 | if node.Kind() == "property_declaration" || node.Kind() == "property_element" { 53 | fmt.Printf("%s PROPERTY DETAIL - ChildCount: %d\n", indent, node.NamedChildCount()) 54 | for i := uint(0); i < node.NamedChildCount(); i++ { 55 | child := node.NamedChild(i) 56 | if child != nil { 57 | childText := string(child.Utf8Text(fileContent)) 58 | fmt.Printf("%s Child %d: Kind=%s, Text=%s\n", indent, i, child.Kind(), childText) 59 | } 60 | } 61 | } 62 | 63 | // Print property promotions with more detail 64 | if node.Kind() == "property_promotion_parameter" { 65 | fmt.Printf("%s PROPERTY PROMOTION DETAIL - ChildCount: %d\n", indent, node.NamedChildCount()) 66 | for i := uint(0); i < node.NamedChildCount(); i++ { 67 | child := node.NamedChild(i) 68 | if child != nil { 69 | childText := string(child.Utf8Text(fileContent)) 70 | fmt.Printf("%s Child %d: Kind=%s, Text=%s\n", indent, i, child.Kind(), childText) 71 | } 72 | } 73 | } 74 | 75 | // Recursively print child nodes 76 | for i := uint(0); i < node.NamedChildCount(); i++ { 77 | printNodeStructure(node.NamedChild(i), fileContent, depth+1) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /internal/lsp/completion/route_completion.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/shopware/shopware-lsp/internal/lsp" 9 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 10 | "github.com/shopware/shopware-lsp/internal/symfony" 11 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 12 | ) 13 | 14 | type RouteCompletionProvider struct { 15 | routeIndex *symfony.RouteIndexer 16 | } 17 | 18 | func NewRouteCompletionProvider(server *lsp.Server) *RouteCompletionProvider { 19 | routeIndexer, _ := server.GetIndexer("symfony.route") 20 | return &RouteCompletionProvider{ 21 | routeIndex: routeIndexer.(*symfony.RouteIndexer), 22 | } 23 | } 24 | 25 | func (p *RouteCompletionProvider) GetCompletions(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 26 | if params.Node == nil { 27 | return []protocol.CompletionItem{} 28 | } 29 | 30 | switch strings.ToLower(filepath.Ext(params.TextDocument.URI)) { 31 | case ".php": 32 | return p.phpCompletions(ctx, params) 33 | case ".twig": 34 | return p.twigCompletions(ctx, params) 35 | default: 36 | return []protocol.CompletionItem{} 37 | } 38 | } 39 | 40 | func (p *RouteCompletionProvider) phpCompletions(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 41 | if treesitterhelper.IsPHPThisMethodCall("redirectToRoute").Matches(params.Node, params.DocumentContent) { 42 | allRoutes, _ := p.routeIndex.GetRoutes() 43 | 44 | var completionItems []protocol.CompletionItem 45 | for _, route := range allRoutes { 46 | completionItems = append(completionItems, protocol.CompletionItem{ 47 | Label: route.Name, 48 | }) 49 | } 50 | 51 | return completionItems 52 | } 53 | 54 | return []protocol.CompletionItem{} 55 | } 56 | 57 | func (p *RouteCompletionProvider) twigCompletions(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 58 | if treesitterhelper.TwigStringInFunctionPattern("seoUrl", "url", "path").Matches(params.Node, []byte(params.DocumentContent)) { 59 | routes, _ := p.routeIndex.GetRoutes() 60 | 61 | var completionItems []protocol.CompletionItem 62 | for _, route := range routes { 63 | completionItems = append(completionItems, protocol.CompletionItem{ 64 | Label: route.Name, 65 | }) 66 | } 67 | 68 | return completionItems 69 | } 70 | 71 | return []protocol.CompletionItem{} 72 | } 73 | 74 | func (p *RouteCompletionProvider) GetTriggerCharacters() []string { 75 | return []string{} 76 | } 77 | -------------------------------------------------------------------------------- /internal/lsp/completion/snippet_completion.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/shopware/shopware-lsp/internal/lsp" 9 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 10 | "github.com/shopware/shopware-lsp/internal/snippet" 11 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 12 | ) 13 | 14 | type SnippetCompletionProvider struct { 15 | snippetIndexer *snippet.SnippetIndexer 16 | } 17 | 18 | func NewSnippetCompletionProvider(lsp *lsp.Server) *SnippetCompletionProvider { 19 | snippetIndexer, _ := lsp.GetIndexer("snippet.indexer") 20 | 21 | return &SnippetCompletionProvider{ 22 | snippetIndexer: snippetIndexer.(*snippet.SnippetIndexer), 23 | } 24 | } 25 | 26 | func (s *SnippetCompletionProvider) GetCompletions(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 27 | if params.Node == nil { 28 | return []protocol.CompletionItem{} 29 | } 30 | 31 | switch strings.ToLower(filepath.Ext(params.TextDocument.URI)) { 32 | case ".twig": 33 | return s.twigCompletion(ctx, params) 34 | case ".php": 35 | return s.phpCompletion(ctx, params) 36 | default: 37 | return []protocol.CompletionItem{} 38 | } 39 | } 40 | 41 | func (s *SnippetCompletionProvider) twigCompletion(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 42 | if treesitterhelper.TwigTransPattern().Matches(params.Node, params.DocumentContent) { 43 | snippets, _ := s.snippetIndexer.GetFrontendSnippets() 44 | 45 | var completionItems []protocol.CompletionItem 46 | for _, snippet := range snippets { 47 | completionItems = append(completionItems, protocol.CompletionItem{ 48 | Label: snippet, 49 | }) 50 | } 51 | 52 | return completionItems 53 | } 54 | 55 | return []protocol.CompletionItem{} 56 | } 57 | 58 | func (s *SnippetCompletionProvider) phpCompletion(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 59 | if treesitterhelper.IsPHPThisMethodCall("trans").Matches(params.Node, params.DocumentContent) { 60 | snippets, _ := s.snippetIndexer.GetFrontendSnippets() 61 | 62 | var completionItems []protocol.CompletionItem 63 | for _, snippet := range snippets { 64 | completionItems = append(completionItems, protocol.CompletionItem{ 65 | Label: snippet, 66 | }) 67 | } 68 | 69 | return completionItems 70 | } 71 | 72 | return []protocol.CompletionItem{} 73 | } 74 | 75 | func (s *SnippetCompletionProvider) GetTriggerCharacters() []string { 76 | return []string{} 77 | } 78 | -------------------------------------------------------------------------------- /internal/tree_sitter_grammars/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 | Comment single line 34 | ================== 35 | {# сomment #} 36 | --- 37 | (template 38 | (comment)) 39 | 40 | ================== 41 | Comment multi line 42 | ================== 43 | {# note: disabled template because we no longer use this 44 | {% for user in users %} 45 | ... 46 | {% endfor %} 47 | #} 48 | --- 49 | (template 50 | (comment)) 51 | 52 | ================== 53 | Сontent сomment content 54 | ================== 55 | Lorem {# сomment #} ipsum 56 | --- 57 | (template 58 | (content) 59 | (comment) 60 | (content)) 61 | 62 | ================== 63 | Comment content Comment 64 | ================== 65 | {# сomment #} Lorem {# сomment #} 66 | --- 67 | (template 68 | (comment) 69 | (content) 70 | (comment)) 71 | 72 | ================== 73 | Inline comments 74 | ================== 75 | {{ 76 | # this is an inline comment 77 | "Hello World"|upper 78 | # this is an inline comment 79 | }} 80 | --- 81 | (template 82 | (output 83 | (inline_comment) 84 | (filter_expression 85 | (string) 86 | (function)) 87 | (inline_comment))) 88 | 89 | ================== 90 | Inline comments 2 91 | ================== 92 | {{ 93 | { 94 | # this is an inline comment 95 | fruit: 'apple', # this is an inline comment 96 | color: 'red', # this is an inline comment 97 | }|join(', ') 98 | }} 99 | --- 100 | (template 101 | (output 102 | (filter_expression 103 | (object 104 | (inline_comment) 105 | (pair 106 | (variable) 107 | (string)) 108 | (inline_comment) 109 | (pair 110 | (variable) 111 | (string)) 112 | (inline_comment)) 113 | (function) 114 | (arguments 115 | (string))))) 116 | 117 | ================== 118 | Output directive 119 | ================== 120 | {{ user }} 121 | --- 122 | (template 123 | (output 124 | (variable))) 125 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Shopware Language Server 2 | 3 | A Language Server Protocol (LSP) implementation for Shopware development. 4 | 5 | ## Features 6 | 7 | ### Symfony Service Support 8 | - Service ID completion in PHP, XML, and YAML files 9 | - Navigation to service definitions from PHP, XML, and YAML 10 | - Service code lens in PHP files showing service usage 11 | - Parameter reference completion and navigation in XML files 12 | - Service tag completion in XML files 13 | - Service class completion in XML and YAML files 14 | - Tag-based service lookup and navigation 15 | - YAML service configuration support for class completion and service references 16 | 17 | ### Twig Template Support 18 | - Template path completion in Twig files (`extends`, `include`, `sw_extends`, `sw_include` tags) 19 | - Template path completion in PHP files (`renderStorefront` method calls) 20 | - Go-to-definition for template paths in Twig and PHP files 21 | - Twig block indexing and tracking 22 | - Support for Shopware-specific Twig extensions and tags 23 | - Icon name completion for `sw_icon` tags 24 | - Icon preview on hover for `sw_icon` tags (shows SVG preview inline) 25 | 26 | ### Snippet Support 27 | - Snippet indexing and validation in Twig files 28 | - Snippet completion in Twig files 29 | - Diagnostics for missing snippets in Twig templates 30 | - Quick Fix to add missing snippets 31 | - Go-to-definition for snippet keys 32 | - Hover support showing all available translations for a snippet key 33 | 34 | ### Route Support 35 | - Route name completion in PHP (`redirectToRoute` method) and Twig files (`seoUrl`, `url`, `path` functions) 36 | - Go-to-definition for route names 37 | - Route parameter completion 38 | 39 | ### Feature Flag Support 40 | - Feature flag indexing and validation 41 | - Go-to-definition for feature flags 42 | - Feature flag completion in PHP files 43 | 44 | ### Diagnostics 45 | - Snippet validation in Twig templates 46 | - Theme icon validation in Twig templates (checks if referenced icons exist) 47 | 48 | ## Development 49 | 50 | ### Requirements 51 | 52 | - Go 1.24 or higher 53 | 54 | ### Building 55 | 56 | ```bash 57 | go build 58 | ``` 59 | 60 | ### Testing 61 | 62 | Run the tests with: 63 | 64 | ```bash 65 | go test ./... 66 | ``` 67 | 68 | Or run tests with race condition detection: 69 | 70 | ```bash 71 | go test -race ./... 72 | ``` 73 | 74 | ### CI/CD 75 | 76 | This project uses GitHub Actions for continuous integration: 77 | 78 | - Tests are run on every push and pull request 79 | - Code linting is performed using golangci-lint 80 | - Builds are created for verification 81 | 82 | ## License 83 | 84 | [MIT License](LICENSE) -------------------------------------------------------------------------------- /internal/feature/indexer.go: -------------------------------------------------------------------------------- 1 | package feature 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/shopware/shopware-lsp/internal/indexer" 9 | sitter "github.com/tree-sitter/go-tree-sitter" 10 | ) 11 | 12 | type FeatureIndexer struct { 13 | featureIndex *indexer.DataIndexer[Feature] 14 | } 15 | 16 | func NewFeatureIndexer(configDir string) (*FeatureIndexer, error) { 17 | featureIndex, err := indexer.NewDataIndexer[Feature](filepath.Join(configDir, "feature_flags.db")) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | return &FeatureIndexer{ 23 | featureIndex: featureIndex, 24 | }, nil 25 | } 26 | 27 | func (i *FeatureIndexer) ID() string { 28 | return "feature.indexer" 29 | } 30 | 31 | func (i *FeatureIndexer) Index(path string, node *sitter.Node, fileContent []byte) error { 32 | // Only index .yaml files that might contain feature flags 33 | if !strings.HasSuffix(path, ".yaml") && !strings.HasSuffix(path, ".yml") { 34 | return nil 35 | } 36 | 37 | // Check if the file contains "feature" in the path 38 | if !strings.Contains(strings.ToLower(path), "feature") { 39 | return nil 40 | } 41 | 42 | // Extract feature flags from the file 43 | features, err := ParseFeatureFile(node, fileContent, path) 44 | if err != nil { 45 | return fmt.Errorf("parsing feature file: %w", err) 46 | } 47 | 48 | // No features found, nothing to do 49 | if len(features) == 0 { 50 | return nil 51 | } 52 | 53 | // Store the features in the database 54 | batchSave := make(map[string]map[string]Feature) 55 | 56 | // Group features by file 57 | for _, feature := range features { 58 | if _, ok := batchSave[feature.File]; !ok { 59 | batchSave[feature.File] = make(map[string]Feature) 60 | } 61 | batchSave[feature.File][feature.Name] = feature 62 | } 63 | 64 | // Save to the database 65 | if err := i.featureIndex.BatchSaveItems(batchSave); err != nil { 66 | return fmt.Errorf("saving features: %w", err) 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func (i *FeatureIndexer) RemovedFiles(paths []string) error { 73 | // Remove files from the database 74 | if err := i.featureIndex.BatchDeleteByFilePaths(paths); err != nil { 75 | return fmt.Errorf("removing features: %w", err) 76 | } 77 | 78 | return nil 79 | } 80 | 81 | func (i *FeatureIndexer) Close() error { 82 | return i.featureIndex.Close() 83 | } 84 | 85 | func (i *FeatureIndexer) Clear() error { 86 | return i.featureIndex.Clear() 87 | } 88 | 89 | func (i *FeatureIndexer) GetFeatureByName(name string) ([]Feature, error) { 90 | return i.featureIndex.GetValues(name) 91 | } 92 | 93 | func (i *FeatureIndexer) GetAllFeatures() ([]Feature, error) { 94 | return i.featureIndex.GetAllValues() 95 | } 96 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | PACKAGE_NAME := shopware-cli 2 | GOLANG_CROSS_VERSION ?= latest 3 | PUBLISH ?= 0 4 | VSCODE_OS ?= $(OS) 5 | 6 | .PHONY: release-dry-run 7 | release-dry-run: 8 | @docker run \ 9 | --rm \ 10 | -e CGO_ENABLED=1 \ 11 | -v `pwd`:/go/src/$(PACKAGE_NAME) \ 12 | -w /go/src/$(PACKAGE_NAME) \ 13 | ghcr.io/goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ 14 | --clean --skip=validate --skip=publish --snapshot 15 | 16 | .PHONY: release 17 | release: 18 | docker run \ 19 | --rm \ 20 | -e CGO_ENABLED=1 \ 21 | -e GITHUB_TOKEN \ 22 | -e HOMEBREW_TAP_GITHUB_TOKEN \ 23 | -v `pwd`:/go/src/$(PACKAGE_NAME) \ 24 | -w /go/src/$(PACKAGE_NAME) \ 25 | ghcr.io/goreleaser/goreleaser-cross:${GOLANG_CROSS_VERSION} \ 26 | release --clean 27 | 28 | .PHONY: release-build-extension 29 | release-build-extension: 30 | @if [ "$(OS)" = "alpine" ]; then \ 31 | VSCODE_OS="alpine"; \ 32 | DOWNLOAD_OS="linux"; \ 33 | else \ 34 | VSCODE_OS="$(VSCODE_OS)"; \ 35 | DOWNLOAD_OS="$(OS)"; \ 36 | fi; \ 37 | echo "VSCODE_OS: $$VSCODE_OS"; \ 38 | echo "DOWNLOAD_OS: $$DOWNLOAD_OS"; \ 39 | mkdir -p dist; \ 40 | tmpDir=$$(mktemp -d); \ 41 | curl -q -L -o "$$tmpDir/shopware-lsp.zip" https://github.com/shopwareLabs/shopware-lsp/releases/download/${VERSION}/shopware-lsp_${VERSION}_$${DOWNLOAD_OS}_$(ARCH).zip; \ 42 | unzip -q "$$tmpDir/shopware-lsp.zip" -d "$$tmpDir"; \ 43 | cp "$$tmpDir/shopware-lsp" ./vscode-extension/shopware-lsp; \ 44 | rm -rf "$$tmpDir"; \ 45 | if [ "$(ARCH)" = "amd64" ]; then \ 46 | RELEASE_ARCH="x64"; \ 47 | else \ 48 | RELEASE_ARCH="$(ARCH)"; \ 49 | fi; \ 50 | (cd ./vscode-extension && npm install); \ 51 | (cd ./vscode-extension && jq '.version = "${VERSION}"' package.json > package.json.tmp && mv package.json.tmp package.json); \ 52 | (cd ./vscode-extension && npx @vscode/vsce package --target $$VSCODE_OS-$$RELEASE_ARCH --pre-release -o ../dist/shopware-lsp-${VERSION}-$$VSCODE_OS-$$RELEASE_ARCH.vsix); \ 53 | rm -rf ./vscode-extension/shopware-lsp; \ 54 | if [ -f "./dist/shopware-lsp-${VERSION}-$$VSCODE_OS-$$RELEASE_ARCH.vsix" ]; then \ 55 | gh release upload ${VERSION} ./dist/shopware-lsp-${VERSION}-$$VSCODE_OS-$$RELEASE_ARCH.vsix || echo "Failed to upload to GitHub release. Release may not exist yet."; \ 56 | if [ "${PUBLISH}" = "1" ]; then \ 57 | npx @vscode/vsce publish --packagePath ./dist/shopware-lsp-${VERSION}-$$VSCODE_OS-$$RELEASE_ARCH.vsix; \ 58 | npx ovsx publish --packagePath ./dist/shopware-lsp-${VERSION}-$$VSCODE_OS-$$RELEASE_ARCH.vsix; \ 59 | else \ 60 | echo "Skipping VSCode extension publish. Set PUBLISH=1 to publish."; \ 61 | fi; \ 62 | else \ 63 | echo "Error: VSIX file was not created successfully."; \ 64 | exit 1; \ 65 | fi 66 | -------------------------------------------------------------------------------- /internal/lsp/types.go: -------------------------------------------------------------------------------- 1 | package lsp 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | 7 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 8 | ) 9 | 10 | // CompletionProvider is an interface for providing completion items 11 | type CompletionProvider interface { 12 | // GetCompletions returns completion items for the given parameters 13 | GetCompletions(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem 14 | // GetTriggerCharacters returns the characters that trigger this completion provider 15 | GetTriggerCharacters() []string 16 | } 17 | 18 | // HoverProvider is an interface for providing hover information 19 | type HoverProvider interface { 20 | // GetHover returns hover information for the given parameters 21 | GetHover(ctx context.Context, params *protocol.HoverParams) (*protocol.Hover, error) 22 | } 23 | 24 | // CodeLensProvider is an interface for providing code lenses 25 | type CodeLensProvider interface { 26 | // GetCodeLenses returns code lenses for the given document 27 | GetCodeLenses(ctx context.Context, params *protocol.CodeLensParams) []protocol.CodeLens 28 | // ResolveCodeLens resolves the command for a given code lens item 29 | ResolveCodeLens(ctx context.Context, codeLens *protocol.CodeLens) (*protocol.CodeLens, error) 30 | } 31 | 32 | // IndexerProvider is an interface for indexers that can be registered with the server 33 | type IndexerProvider interface { 34 | // ID returns a unique identifier for this indexer 35 | ID() string 36 | // Index builds or updates the index 37 | // If forceReindex is true, it will clear the existing index before rebuilding 38 | Index(forceReindex bool) error 39 | // Close cleans up resources used by the indexer 40 | Close() error 41 | 42 | FileCreated(ctx context.Context, params *protocol.CreateFilesParams) error 43 | FileRenamed(ctx context.Context, params *protocol.RenameFilesParams) error 44 | FileDeleted(ctx context.Context, params *protocol.DeleteFilesParams) error 45 | } 46 | 47 | // IndexerRegistry provides access to registered indexers 48 | type IndexerRegistry interface { 49 | // RegisterIndexer adds an indexer to the registry 50 | RegisterIndexer(indexer IndexerProvider) 51 | // GetIndexer retrieves an indexer by ID 52 | GetIndexer(id string) (IndexerProvider, bool) 53 | // GetAllIndexers returns all registered indexers 54 | GetAllIndexers() []IndexerProvider 55 | // IndexAll builds or updates all registered indexes 56 | IndexAll() error 57 | // CloseAll closes all registered indexers 58 | CloseAll() error 59 | } 60 | 61 | type CommandFunc func(ctx context.Context, args *json.RawMessage) (interface{}, error) 62 | 63 | type CommandProvider interface { 64 | GetCommands(ctx context.Context) map[string]CommandFunc 65 | } 66 | -------------------------------------------------------------------------------- /internal/twig/parser.go: -------------------------------------------------------------------------------- 1 | package twig 2 | 3 | import ( 4 | "strings" 5 | 6 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 7 | ) 8 | 9 | type TwigFile struct { 10 | // Name of the bundle 11 | BundleName string 12 | Path string 13 | // Relative Path, used inside of Twig 14 | RelPath string 15 | Blocks map[string]TwigBlock 16 | ExtendsFile string 17 | ExtendsTagLine int 18 | } 19 | 20 | type TwigBlock struct { 21 | Name string 22 | Line int 23 | } 24 | 25 | // findBlocks recursively traverses the tree to find all blocks 26 | func findBlocks(node *tree_sitter.Node, content []byte, file *TwigFile) { 27 | if node.Kind() == "block" { 28 | for i := 0; i < int(node.NamedChildCount()); i++ { 29 | child := node.NamedChild(uint(i)) 30 | if child.Kind() == "identifier" { 31 | blockName := string(child.Utf8Text(content)) 32 | file.Blocks[blockName] = TwigBlock{ 33 | Name: blockName, 34 | Line: int(child.Range().StartPoint.Row) + 1, 35 | } 36 | break 37 | } 38 | } 39 | } 40 | 41 | // Recursively process all named children 42 | for i := 0; i < int(node.NamedChildCount()); i++ { 43 | findBlocks(node.NamedChild(uint(i)), content, file) 44 | } 45 | } 46 | 47 | func ParseTwig(filePath string, node *tree_sitter.Node, content []byte) (*TwigFile, error) { 48 | file := &TwigFile{ 49 | Path: filePath, 50 | BundleName: getBundleNameByPath(filePath), 51 | RelPath: ConvertToRelativePath(filePath), 52 | Blocks: make(map[string]TwigBlock), 53 | } 54 | 55 | // Find all blocks recursively 56 | findBlocks(node, content, file) 57 | 58 | // Find extends tag 59 | var cursor = node.Walk() 60 | defer cursor.Close() 61 | 62 | if cursor.GotoFirstChild() { 63 | for { 64 | node := cursor.Node() 65 | 66 | if node.Kind() == "tag" { 67 | // Check if this is an extends tag by examining the tag text 68 | tagText := string(node.Utf8Text(content)) 69 | isExtendsTag := false 70 | 71 | // Check if the tag contains "extends" or "sw_extends" 72 | if strings.Contains(tagText, "extends") || strings.Contains(tagText, "sw_extends") { 73 | isExtendsTag = true 74 | } 75 | 76 | // If it's an extends tag, look for the string parameter 77 | if isExtendsTag { 78 | for i := 0; i < int(node.NamedChildCount()); i++ { 79 | child := node.NamedChild(uint(i)) 80 | 81 | if child.Kind() == "string" { 82 | file.ExtendsFile = strings.Trim(strings.Trim(string(child.Utf8Text(content)), "\""), "'") 83 | file.ExtendsTagLine = int(node.Range().StartPoint.Row) + 1 84 | break 85 | } 86 | } 87 | } 88 | } 89 | 90 | if !cursor.GotoNextSibling() { 91 | break 92 | } 93 | } 94 | } 95 | 96 | return file, nil 97 | } 98 | -------------------------------------------------------------------------------- /internal/extension/bundle.go: -------------------------------------------------------------------------------- 1 | package extension 2 | 3 | import ( 4 | "path/filepath" 5 | "slices" 6 | "strings" 7 | 8 | "github.com/shopware/shopware-lsp/internal/php" 9 | ) 10 | 11 | // isShopwareBundle checks if a class extends Shopware\Core\Framework\Bundle or Shopware\Core\Framework\Plugin 12 | func isShopwareBundle(class php.PHPClass) bool { 13 | if class.IsInterface || class.Parent == "" { 14 | return false 15 | } 16 | 17 | return class.Parent == "\\Shopware\\Core\\Framework\\Bundle" || 18 | class.Parent == "Shopware\\Core\\Framework\\Bundle" || 19 | class.Parent == "\\Shopware\\Core\\Framework\\Plugin" || 20 | class.Parent == "Shopware\\Core\\Framework\\Plugin" 21 | } 22 | 23 | // createBundleFromClass creates a ShopwareExtension instance from a PHP class 24 | func createBundleFromClass(class php.PHPClass) ShopwareExtension { 25 | // Extract the last part of the fully qualified class name 26 | nameParts := strings.Split(class.Name, "\\") 27 | name := class.Name 28 | if len(nameParts) > 0 { 29 | name = nameParts[len(nameParts)-1] 30 | } 31 | 32 | return ShopwareExtension{ 33 | Name: name, 34 | Path: class.Path, 35 | Type: ShopwareExtensionTypeBundle, 36 | } 37 | } 38 | 39 | var coreBundles = []string{ 40 | "Administration.php", 41 | "Checkout.php", 42 | "DevOps.php", 43 | "Framework.php", 44 | "Plugin.php", 45 | "Maintenance.php", 46 | "Profiling.php", 47 | "Service.php", 48 | "Content.php", 49 | "System.php", 50 | "Elasticsearch.php", 51 | "Storefront.php", 52 | } 53 | 54 | // isValidForIndex checks if a file should be indexed 55 | func isValidForIndex(filePath string) bool { 56 | // Handle test directories in the path 57 | pathParts := strings.Split(filepath.ToSlash(filePath), "/") 58 | for _, part := range pathParts { 59 | partLower := strings.ToLower(part) 60 | if partLower == "tests" || partLower == "test" || 61 | partLower == "fixtures" || partLower == "_fixture" || 62 | partLower == "_fixtures" { 63 | return false 64 | } 65 | } 66 | 67 | // Skip hidden files 68 | fileName := filepath.Base(filePath) 69 | if strings.HasPrefix(fileName, ".") { 70 | return false 71 | } 72 | 73 | if slices.Contains(coreBundles, fileName) { 74 | // Skip all core bundle files 75 | return false 76 | } 77 | 78 | // Handle test files but make exceptions for bundle and plugin classes 79 | fileNameLower := strings.ToLower(fileName) 80 | if strings.Contains(fileNameLower, "test") { 81 | // Skip all test files except TestBundle.php and TestPlugin.php (which may be valid bundle classes) 82 | if !strings.HasSuffix(fileNameLower, "bundle.php") && !strings.HasSuffix(fileNameLower, "plugin.php") { 83 | return false 84 | } 85 | } 86 | 87 | // If we got this far, the file should be indexed 88 | return true 89 | } 90 | -------------------------------------------------------------------------------- /internal/lsp/completion/theme_completion.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "strings" 7 | 8 | "github.com/shopware/shopware-lsp/internal/lsp" 9 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 10 | "github.com/shopware/shopware-lsp/internal/theme" 11 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 12 | ) 13 | 14 | type ThemeCompletionProvider struct { 15 | themeIndexer *theme.ThemeConfigIndexer 16 | } 17 | 18 | func NewThemeCompletionProvider(lspServer *lsp.Server) *ThemeCompletionProvider { 19 | themeIndexer, _ := lspServer.GetIndexer("theme.indexer") 20 | return &ThemeCompletionProvider{ 21 | themeIndexer: themeIndexer.(*theme.ThemeConfigIndexer), 22 | } 23 | } 24 | 25 | func (p *ThemeCompletionProvider) GetCompletions(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 26 | if params.Node == nil { 27 | return []protocol.CompletionItem{} 28 | } 29 | 30 | switch strings.ToLower(filepath.Ext(params.TextDocument.URI)) { 31 | case ".scss": 32 | return p.scssCompletions(ctx, params) 33 | case ".twig": 34 | return p.twigCompletions(ctx, params) 35 | default: 36 | return []protocol.CompletionItem{} 37 | } 38 | } 39 | 40 | func (p *ThemeCompletionProvider) scssCompletions(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 41 | var completionItems []protocol.CompletionItem 42 | 43 | elements, _ := p.themeIndexer.GetAllThemeConfigFields() 44 | uniqueElements := make(map[string]struct{}) 45 | 46 | for _, element := range elements { 47 | if !element.Scss { 48 | continue 49 | } 50 | 51 | if _, exists := uniqueElements[element.Key]; !exists { 52 | uniqueElements[element.Key] = struct{}{} 53 | completionItems = append(completionItems, protocol.CompletionItem{ 54 | Label: "$" + element.Key, 55 | }) 56 | } 57 | } 58 | 59 | return completionItems 60 | } 61 | 62 | func (p *ThemeCompletionProvider) twigCompletions(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 63 | if treesitterhelper.TwigStringInFunctionPattern("theme_config").Matches(params.Node, params.DocumentContent) { 64 | themes, _ := p.themeIndexer.GetThemeConfigFields() 65 | 66 | uniqueThemes := make(map[string]struct{}) 67 | var completionItems []protocol.CompletionItem 68 | for _, theme := range themes { 69 | if _, exists := uniqueThemes[theme]; !exists { 70 | uniqueThemes[theme] = struct{}{} 71 | completionItems = append(completionItems, protocol.CompletionItem{ 72 | Label: theme, 73 | }) 74 | } 75 | } 76 | 77 | return completionItems 78 | } 79 | 80 | return []protocol.CompletionItem{} 81 | } 82 | 83 | func (p *ThemeCompletionProvider) GetTriggerCharacters() []string { 84 | return []string{} 85 | } 86 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | builds: 3 | - id: darwin-amd64 4 | main: ./ 5 | binary: shopware-lsp 6 | goos: 7 | - darwin 8 | goarch: 9 | - amd64 10 | env: 11 | - PKG_CONFIG_SYSROOT_DIR=/sysroot/macos/amd64 12 | - PKG_CONFIG_PATH=/sysroot/macos/amd64/usr/local/lib/pkgconfig 13 | - CC=o64-clang 14 | - CXX=o64-clang++ 15 | - CGO_ENABLED=1 16 | ldflags: 17 | - -s -w -X main.version={{.Version}} 18 | - id: darwin-arm64 19 | main: ./ 20 | binary: shopware-lsp 21 | goos: 22 | - darwin 23 | goarch: 24 | - arm64 25 | env: 26 | - PKG_CONFIG_SYSROOT_DIR=/sysroot/macos/arm64 27 | - PKG_CONFIG_PATH=/sysroot/macos/arm64/usr/local/lib/pkgconfig 28 | - CC=oa64-clang 29 | - CXX=oa64-clang++ 30 | - CGO_ENABLED=1 31 | ldflags: 32 | - -s -w -X main.version={{.Version}} 33 | - id: linux-amd64 34 | main: ./ 35 | binary: shopware-lsp 36 | goos: 37 | - linux 38 | goarch: 39 | - amd64 40 | ldflags: 41 | - -s -w -linkmode external -extldflags -static -X main.version={{.Version}} 42 | env: 43 | - PKG_CONFIG_SYSROOT_DIR=/sysroot/linux/amd64 44 | - PKG_CONFIG_PATH=/sysroot/linux/amd64/usr/local/lib/pkgconfig 45 | - CC=x86_64-linux-gnu-gcc 46 | - CXX=x86_64-linux-gnu-g++ 47 | - CGO_ENABLED=1 48 | - id: linux-arm64 49 | main: ./ 50 | binary: shopware-lsp 51 | goos: 52 | - linux 53 | goarch: 54 | - arm64 55 | ldflags: 56 | - -s -w -linkmode external -extldflags -static -X main.version={{.Version}} 57 | env: 58 | - PKG_CONFIG_SYSROOT_DIR=/sysroot/linux/arm64 59 | - PKG_CONFIG_PATH=/sysroot/linux/arm64/usr/local/lib/pkgconfig 60 | - CC=aarch64-linux-gnu-gcc 61 | - CXX=aarch64-linux-gnu-g++ 62 | - CGO_ENABLED=1 63 | archives: 64 | - id: golang-cross 65 | ids: 66 | - darwin-amd64 67 | - darwin-arm64 68 | - linux-amd64 69 | - linux-arm64 70 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 71 | formats: [ 'zip' ] 72 | wrap_in_directory: false 73 | checksum: 74 | name_template: 'checksums.txt' 75 | changelog: 76 | sort: asc 77 | filters: 78 | exclude: 79 | - '^docs:' 80 | - '^test:' 81 | release: 82 | github: 83 | owner: shopwareLabs 84 | name: shopware-lsp 85 | 86 | brews: 87 | - repository: 88 | owner: shopware 89 | name: homebrew-tap 90 | token: "{{ .Env.HOMEBREW_TAP_GITHUB_TOKEN }}" 91 | commit_author: 92 | name: Frosh Automation 93 | email: ci@fos.gg 94 | goarm: "7" 95 | homepage: https://shopware.com 96 | description: Shopware Language Server 97 | license: MIT 98 | directory: Formula 99 | install: |- 100 | bin.install "shopware-lsp" 101 | -------------------------------------------------------------------------------- /LSP.md: -------------------------------------------------------------------------------- 1 | # Shopware LSP Custom Commands 2 | 3 | This document lists the custom LSP commands and notifications provided by the Shopware Language Server. Each entry shows the method name, expected parameters and a short description of the action that is executed. 4 | 5 | ## Commands 6 | 7 | ### `shopware/forceReindex` 8 | * **Parameters:** none 9 | * **Action:** Forces a rebuild of all indexes by invoking `indexAll` with `forceReindex` set to `true`. 10 | * **Returns:** `{ "message": "Force reindexing started" }` 11 | 12 | ### `shopware/extension/all` 13 | * **Parameters:** none 14 | * **Action:** Returns all detected Shopware extensions via the `ExtensionIndexer`. 15 | * **Returns:** array of objects with `Name`, `Type` and `Path` fields. 16 | 17 | ### `shopware/snippet/getPossibleSnippetFilse` 18 | * **Parameters:** `{ "fileUri": string }` 19 | * **Action:** Searches the snippet directory for JSON files or creates a default `storefront.en-GB.json` if none exist. 20 | * **Returns:** `{ "paths": [ { "path": string, "name": string, "value": string } ] }` 21 | 22 | ### `shopware/snippet/create` 23 | * **Parameters:** 24 | ```json 25 | { 26 | "fileUri": string, 27 | "snippetKey": string, 28 | "snippets": [ { "path": string, "name": string, "value": string } ] 29 | } 30 | ``` 31 | * **Action:** Adds the provided snippet value to the given JSON files, reindexes them and publishes diagnostics for the original document. 32 | * **Returns:** `null` 33 | 34 | ### `shopware/snippet/all` 35 | * **Parameters:** none 36 | * **Action:** Collects all snippet keys from the indexed snippet files. 37 | * **Returns:** array of objects `{ key, text, file }` sorted alphabetically. 38 | 39 | ### `shopware/twig/extendBlock` 40 | * **Parameters:** 41 | ```json 42 | { "textUri": string, "blockName": string, "extension": string } 43 | ``` 44 | * **Action:** Creates or updates a Twig template in the selected extension so that it extends the given block. A new file is created if necessary and the block is inserted. 45 | * **Returns:** on success `{ "uri": string, "line": number }`; otherwise an error object with `code` and `message`. 46 | 47 | ## Notifications 48 | 49 | ### `shopware/indexingStarted` 50 | Sent when the server begins indexing. No parameters are required. 51 | 52 | ### `shopware/indexingCompleted` 53 | Sent when indexing finishes. Parameters: 54 | ```json 55 | { "message": string, "timeInSeconds": number } 56 | ``` 57 | 58 | ## Using with Neovim 59 | 60 | The server produces a single binary. Build it using: 61 | 62 | ```bash 63 | go build -o shopware-lsp 64 | ``` 65 | 66 | In Neovim, configure the language server using `lspconfig`: 67 | 68 | ```lua 69 | require('lspconfig').shopware_lsp = { 70 | default_config = { 71 | cmd = { '/path/to/shopware-lsp' }, 72 | filetypes = { 'php', 'twig', 'xml', 'yaml' }, 73 | root_dir = vim.loop.cwd, 74 | }, 75 | } 76 | 77 | require('lspconfig')['shopware_lsp'].setup{} 78 | ``` 79 | 80 | This registers the binary with Neovim’s built‑in LSP client and enables the custom commands above via `vim.lsp.buf.execute_command` or `client.request`. 81 | -------------------------------------------------------------------------------- /internal/lsp/definition/route_definition.go: -------------------------------------------------------------------------------- 1 | package definition 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/shopware/shopware-lsp/internal/lsp" 10 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 11 | "github.com/shopware/shopware-lsp/internal/symfony" 12 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 13 | ) 14 | 15 | type RouteDefinitionProvider struct { 16 | routeIndex *symfony.RouteIndexer 17 | } 18 | 19 | func NewRouteDefinitionProvider(server *lsp.Server) *RouteDefinitionProvider { 20 | routeIndexer, _ := server.GetIndexer("symfony.route") 21 | return &RouteDefinitionProvider{ 22 | routeIndex: routeIndexer.(*symfony.RouteIndexer), 23 | } 24 | } 25 | 26 | func (p *RouteDefinitionProvider) GetDefinition(ctx context.Context, params *protocol.DefinitionParams) []protocol.Location { 27 | if params.Node == nil { 28 | return []protocol.Location{} 29 | } 30 | 31 | switch strings.ToLower(filepath.Ext(params.TextDocument.URI)) { 32 | case ".php": 33 | return p.phpDefinition(ctx, params) 34 | case ".twig": 35 | return p.twigDefinition(ctx, params) 36 | default: 37 | return []protocol.Location{} 38 | } 39 | } 40 | 41 | func (p *RouteDefinitionProvider) phpDefinition(ctx context.Context, params *protocol.DefinitionParams) []protocol.Location { 42 | if treesitterhelper.IsPHPThisMethodCall("redirectToRoute").Matches(params.Node, params.DocumentContent) { 43 | currentText := treesitterhelper.GetNodeText(params.Node, params.DocumentContent) 44 | 45 | locations, _ := p.routeIndex.GetRoute(currentText) 46 | 47 | var result []protocol.Location 48 | for _, location := range locations { 49 | result = append(result, protocol.Location{ 50 | URI: fmt.Sprintf("file://%s", location.FilePath), 51 | Range: protocol.Range{ 52 | Start: protocol.Position{ 53 | Line: location.Line - 1, 54 | Character: 0, 55 | }, 56 | End: protocol.Position{ 57 | Line: location.Line - 1, 58 | Character: 0, 59 | }, 60 | }, 61 | }) 62 | } 63 | 64 | return result 65 | } 66 | 67 | return []protocol.Location{} 68 | } 69 | 70 | func (p *RouteDefinitionProvider) twigDefinition(ctx context.Context, params *protocol.DefinitionParams) []protocol.Location { 71 | if treesitterhelper.TwigStringInFunctionPattern("seoUrl", "url", "path").Matches(params.Node, []byte(params.DocumentContent)) { 72 | routes, _ := p.routeIndex.GetRoute(treesitterhelper.GetNodeText(params.Node, params.DocumentContent)) 73 | 74 | var locations []protocol.Location 75 | for _, route := range routes { 76 | locations = append(locations, protocol.Location{ 77 | URI: fmt.Sprintf("file://%s", route.FilePath), 78 | Range: protocol.Range{ 79 | Start: protocol.Position{ 80 | Line: route.Line - 1, 81 | Character: 0, 82 | }, 83 | End: protocol.Position{ 84 | Line: route.Line - 1, 85 | Character: 0, 86 | }, 87 | }, 88 | }) 89 | } 90 | 91 | return locations 92 | } 93 | 94 | return []protocol.Location{} 95 | } 96 | -------------------------------------------------------------------------------- /internal/lsp/definition/theme_definition.go: -------------------------------------------------------------------------------- 1 | package definition 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/shopware/shopware-lsp/internal/lsp" 10 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 11 | "github.com/shopware/shopware-lsp/internal/theme" 12 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 13 | ) 14 | 15 | type ThemeDefinitionProvider struct { 16 | themeIndexer *theme.ThemeConfigIndexer 17 | } 18 | 19 | func NewThemeDefinitionProvider(lspServer *lsp.Server) *ThemeDefinitionProvider { 20 | themeIndexer, _ := lspServer.GetIndexer("theme.indexer") 21 | return &ThemeDefinitionProvider{ 22 | themeIndexer: themeIndexer.(*theme.ThemeConfigIndexer), 23 | } 24 | } 25 | 26 | func (p *ThemeDefinitionProvider) GetDefinition(ctx context.Context, params *protocol.DefinitionParams) []protocol.Location { 27 | if params.Node == nil { 28 | return []protocol.Location{} 29 | } 30 | 31 | switch strings.ToLower(filepath.Ext(params.TextDocument.URI)) { 32 | case ".scss": 33 | return p.scssDefinition(ctx, params) 34 | case ".twig": 35 | return p.twigDefinition(ctx, params) 36 | default: 37 | return []protocol.Location{} 38 | } 39 | } 40 | 41 | func (p *ThemeDefinitionProvider) scssDefinition(ctx context.Context, params *protocol.DefinitionParams) []protocol.Location { 42 | if params.Node.Kind() == "variable" { 43 | nodeText := treesitterhelper.GetNodeText(params.Node, params.DocumentContent) 44 | locations, _ := p.themeIndexer.GetThemeConfigField(strings.TrimPrefix(nodeText, "$")) 45 | 46 | var result []protocol.Location 47 | for _, location := range locations { 48 | result = append(result, protocol.Location{ 49 | URI: fmt.Sprintf("file://%s", location.Path), 50 | Range: protocol.Range{ 51 | Start: protocol.Position{ 52 | Line: location.Line - 1, 53 | Character: 0, 54 | }, 55 | End: protocol.Position{ 56 | Line: location.Line - 1, 57 | Character: 0, 58 | }, 59 | }, 60 | }) 61 | } 62 | 63 | return result 64 | } 65 | 66 | return []protocol.Location{} 67 | } 68 | 69 | func (p *ThemeDefinitionProvider) twigDefinition(ctx context.Context, params *protocol.DefinitionParams) []protocol.Location { 70 | 71 | if treesitterhelper.TwigStringInFunctionPattern("theme_config").Matches(params.Node, params.DocumentContent) { 72 | nodeText := treesitterhelper.GetNodeText(params.Node, params.DocumentContent) 73 | locations, _ := p.themeIndexer.GetThemeConfigField(nodeText) 74 | 75 | var result []protocol.Location 76 | for _, location := range locations { 77 | result = append(result, protocol.Location{ 78 | URI: fmt.Sprintf("file://%s", location.Path), 79 | Range: protocol.Range{ 80 | Start: protocol.Position{ 81 | Line: location.Line - 1, 82 | Character: 0, 83 | }, 84 | End: protocol.Position{ 85 | Line: location.Line - 1, 86 | Character: 0, 87 | }, 88 | }, 89 | }) 90 | } 91 | 92 | return result 93 | } 94 | 95 | return []protocol.Location{} 96 | } 97 | -------------------------------------------------------------------------------- /internal/lsp/definition/snippet_definition.go: -------------------------------------------------------------------------------- 1 | package definition 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/shopware/shopware-lsp/internal/lsp" 10 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 11 | "github.com/shopware/shopware-lsp/internal/snippet" 12 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 13 | ) 14 | 15 | type SnippetDefinitionProvider struct { 16 | snippetIndexer *snippet.SnippetIndexer 17 | } 18 | 19 | func NewSnippetDefinitionProvider(lspServer *lsp.Server) *SnippetDefinitionProvider { 20 | snippetIndexer, _ := lspServer.GetIndexer("snippet.indexer") 21 | return &SnippetDefinitionProvider{ 22 | snippetIndexer: snippetIndexer.(*snippet.SnippetIndexer), 23 | } 24 | } 25 | 26 | func (s *SnippetDefinitionProvider) GetDefinition(ctx context.Context, params *protocol.DefinitionParams) []protocol.Location { 27 | if params.Node == nil { 28 | return []protocol.Location{} 29 | } 30 | 31 | switch strings.ToLower(filepath.Ext(params.TextDocument.URI)) { 32 | case ".twig": 33 | return s.twigDefinitions(ctx, params) 34 | case ".php": 35 | return s.phpDefinitions(ctx, params) 36 | default: 37 | return []protocol.Location{} 38 | } 39 | } 40 | 41 | func (s *SnippetDefinitionProvider) twigDefinitions(ctx context.Context, params *protocol.DefinitionParams) []protocol.Location { 42 | if treesitterhelper.TwigTransPattern().Matches(params.Node, params.DocumentContent) { 43 | snippets, _ := s.snippetIndexer.GetFrontendSnippet(treesitterhelper.GetNodeText(params.Node, params.DocumentContent)) 44 | 45 | var locations []protocol.Location 46 | 47 | for _, snippet := range snippets { 48 | locations = append(locations, protocol.Location{ 49 | URI: fmt.Sprintf("file://%s", snippet.File), 50 | Range: protocol.Range{ 51 | Start: protocol.Position{ 52 | Line: snippet.Line - 1, 53 | Character: 0, 54 | }, 55 | End: protocol.Position{ 56 | Line: snippet.Line - 1, 57 | Character: 0, 58 | }, 59 | }, 60 | }) 61 | } 62 | 63 | return locations 64 | } 65 | 66 | return []protocol.Location{} 67 | } 68 | 69 | func (s *SnippetDefinitionProvider) phpDefinitions(ctx context.Context, params *protocol.DefinitionParams) []protocol.Location { 70 | if treesitterhelper.IsPHPThisMethodCall("trans").Matches(params.Node, params.DocumentContent) { 71 | value := treesitterhelper.GetNodeText(params.Node, params.DocumentContent) 72 | snippets, _ := s.snippetIndexer.GetFrontendSnippet(value) 73 | 74 | var locations []protocol.Location 75 | for _, snippet := range snippets { 76 | locations = append(locations, protocol.Location{ 77 | URI: fmt.Sprintf("file://%s", snippet.File), 78 | Range: protocol.Range{ 79 | Start: protocol.Position{ 80 | Line: snippet.Line - 1, 81 | Character: 0, 82 | }, 83 | End: protocol.Position{ 84 | Line: snippet.Line - 1, 85 | Character: 0, 86 | }, 87 | }, 88 | }) 89 | } 90 | 91 | return locations 92 | } 93 | 94 | return []protocol.Location{} 95 | } 96 | -------------------------------------------------------------------------------- /internal/extension/indexer.go: -------------------------------------------------------------------------------- 1 | package extension 2 | 3 | import ( 4 | "log" 5 | "path/filepath" 6 | 7 | "github.com/shopware/shopware-lsp/internal/indexer" 8 | "github.com/shopware/shopware-lsp/internal/php" 9 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 10 | ) 11 | 12 | type ExtensionIndexer struct { 13 | indexer *indexer.DataIndexer[ShopwareExtension] 14 | } 15 | 16 | func NewExtensionIndexer(configDir string) (*ExtensionIndexer, error) { 17 | indexer, err := indexer.NewDataIndexer[ShopwareExtension](filepath.Join(configDir, "extension.db")) 18 | if err != nil { 19 | return nil, err 20 | } 21 | 22 | return &ExtensionIndexer{ 23 | indexer: indexer, 24 | }, nil 25 | } 26 | 27 | func (idx *ExtensionIndexer) ID() string { 28 | return "extension.indexer" 29 | } 30 | 31 | func (idx *ExtensionIndexer) Index(path string, node *tree_sitter.Node, fileContent []byte) error { 32 | if !isValidForIndex(path) { 33 | return nil 34 | } 35 | 36 | switch filepath.Ext(path) { 37 | case ".php": 38 | return idx.indexBundle(path, node, fileContent) 39 | case ".xml": 40 | return idx.indexApp(path, node, fileContent) 41 | default: 42 | return nil 43 | } 44 | } 45 | 46 | func (idx *ExtensionIndexer) indexBundle(path string, node *tree_sitter.Node, fileContent []byte) error { 47 | classes := php.GetClassesOfFileWithParser(path, node, fileContent) 48 | if len(classes) == 0 { 49 | return nil 50 | } 51 | for _, class := range classes { 52 | if isShopwareBundle(class) { 53 | extension := createBundleFromClass(class) 54 | return idx.indexer.SaveItem(path, extension.Name, extension) 55 | } 56 | } 57 | return nil 58 | } 59 | 60 | func (idx *ExtensionIndexer) indexApp(path string, node *tree_sitter.Node, fileContent []byte) error { 61 | if filepath.Base(path) != "manifest.xml" { 62 | return nil 63 | } 64 | 65 | manifest, err := ParseManifestXml(path, node, fileContent) 66 | 67 | if err != nil { 68 | log.Printf("Error parsing manifest.xml: %v", err) 69 | return err 70 | } 71 | 72 | if manifest == nil { 73 | return nil 74 | } 75 | 76 | app := ShopwareExtension{ 77 | Name: manifest.Name, 78 | Type: ShopwareExtensionTypeApp, 79 | Path: filepath.Dir(path), 80 | } 81 | 82 | return idx.indexer.SaveItem(path, manifest.Name, app) 83 | } 84 | 85 | func (idx *ExtensionIndexer) GetExtensionByName(name string) *ShopwareExtension { 86 | extension, err := idx.indexer.GetValues(name) 87 | if err != nil { 88 | return nil 89 | } 90 | 91 | if len(extension) == 0 { 92 | return nil 93 | } 94 | 95 | return &extension[0] 96 | } 97 | 98 | func (idx *ExtensionIndexer) RemovedFiles(paths []string) error { 99 | return idx.indexer.BatchDeleteByFilePaths(paths) 100 | } 101 | 102 | func (idx *ExtensionIndexer) Close() error { 103 | return idx.indexer.Close() 104 | } 105 | 106 | func (idx *ExtensionIndexer) Clear() error { 107 | return idx.indexer.Clear() 108 | } 109 | 110 | func (idx *ExtensionIndexer) GetAll() ([]ShopwareExtension, error) { 111 | return idx.indexer.GetAllValues() 112 | } 113 | -------------------------------------------------------------------------------- /internal/systemconfig/systemconfig_indexer.go: -------------------------------------------------------------------------------- 1 | package systemconfig 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/shopware/shopware-lsp/internal/indexer" 8 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 9 | ) 10 | 11 | // SystemConfigIndexer is responsible for indexing system config XML files 12 | type SystemConfigIndexer struct { 13 | configIndex *indexer.DataIndexer[SystemConfigEntry] 14 | } 15 | 16 | // NewSystemConfigIndexer creates a new system config indexer 17 | func NewSystemConfigIndexer(configDir string) (*SystemConfigIndexer, error) { 18 | configIndexer, err := indexer.NewDataIndexer[SystemConfigEntry](filepath.Join(configDir, "system_config.db")) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | return &SystemConfigIndexer{ 24 | configIndex: configIndexer, 25 | }, nil 26 | } 27 | 28 | // ID returns the unique identifier for this indexer 29 | func (s *SystemConfigIndexer) ID() string { 30 | return "systemconfig.indexer" 31 | } 32 | 33 | // Index processes a file and indexes any system config entries found 34 | func (s *SystemConfigIndexer) Index(path string, node *tree_sitter.Node, fileContent []byte) error { 35 | // Skip non-system config files 36 | if !strings.HasSuffix(path, ".xml") || strings.Contains(path, "/_fixtures/") || strings.Contains(path, "/_fixture/") { 37 | return nil 38 | } 39 | 40 | // Check if it's a system config XML file 41 | if !IsSystemConfigXML(fileContent) { 42 | return nil 43 | } 44 | 45 | // We already have the file content, so we can pass it directly 46 | entries, err := IndexSystemConfigFile(path, node, fileContent) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | // Prepare batch save 52 | batchSave := make(map[string]map[string]SystemConfigEntry) 53 | 54 | for _, entry := range entries { 55 | if _, ok := batchSave[entry.FilePath]; !ok { 56 | batchSave[entry.FilePath] = make(map[string]SystemConfigEntry) 57 | } 58 | batchSave[entry.FilePath][entry.Name] = entry 59 | } 60 | 61 | return s.configIndex.BatchSaveItems(batchSave) 62 | } 63 | 64 | // RemovedFiles handles cleanup when files are removed 65 | func (s *SystemConfigIndexer) RemovedFiles(paths []string) error { 66 | return s.configIndex.BatchDeleteByFilePaths(paths) 67 | } 68 | 69 | // Close closes the indexer 70 | func (s *SystemConfigIndexer) Close() error { 71 | return s.configIndex.Close() 72 | } 73 | 74 | // Clear clears all indexed data 75 | func (s *SystemConfigIndexer) Clear() error { 76 | return s.configIndex.Clear() 77 | } 78 | 79 | // GetSystemConfigEntries returns all system config entry keys 80 | func (s *SystemConfigIndexer) GetSystemConfigEntries() ([]string, error) { 81 | return s.configIndex.GetAllKeys() 82 | } 83 | 84 | // GetSystemConfigEntry returns all entries for a specific key 85 | func (s *SystemConfigIndexer) GetSystemConfigEntry(key string) ([]SystemConfigEntry, error) { 86 | return s.configIndex.GetValues(key) 87 | } 88 | 89 | // GetAllSystemConfigEntries returns all system config entries 90 | func (s *SystemConfigIndexer) GetAllSystemConfigEntries() ([]SystemConfigEntry, error) { 91 | return s.configIndex.GetAllValues() 92 | } 93 | -------------------------------------------------------------------------------- /internal/snippet/snippet.go: -------------------------------------------------------------------------------- 1 | package snippet 2 | 3 | import ( 4 | "strings" 5 | 6 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 7 | ) 8 | 9 | type Snippet struct { 10 | Key string 11 | Text string 12 | File string 13 | Line int 14 | } 15 | 16 | func parseSnippetFile(root *tree_sitter.Node, document []byte, filePath string) (map[string]Snippet, error) { 17 | // Find the object node which is the first child of the document node 18 | if root.Kind() == "document" && root.NamedChildCount() > 0 { 19 | root = root.NamedChild(0) // Get the object node 20 | } 21 | 22 | result := make(map[string]Snippet) 23 | extractValues("", root, document, result, filePath) 24 | 25 | return result, nil 26 | } 27 | 28 | func extractValues(prefix string, node *tree_sitter.Node, content []byte, result map[string]Snippet, filePath string) { 29 | // Check if this is an object with key-value pairs 30 | if node.Kind() == "object" { 31 | // Iterate through child nodes 32 | for i := 0; i < int(node.NamedChildCount()); i++ { 33 | pair := node.NamedChild(uint(i)) 34 | 35 | if pair.Kind() == "pair" { 36 | // Get key and value 37 | key := pair.NamedChild(0) 38 | value := pair.NamedChild(1) 39 | 40 | if key != nil && key.Kind() == "string" { 41 | // Find the string_content node inside the string node 42 | var keyText string 43 | if key.NamedChildCount() > 0 && key.NamedChild(0).Kind() == "string_content" { 44 | keyContent := key.NamedChild(0) 45 | keyText = string(keyContent.Utf8Text(content)) 46 | } else { 47 | // Fallback 48 | keyText = string(key.Utf8Text(content)) 49 | keyText = strings.Trim(keyText, "\"") 50 | } 51 | 52 | // Build the new prefix 53 | newPrefix := keyText 54 | if prefix != "" { 55 | newPrefix = prefix + "." + keyText 56 | } 57 | 58 | if value.Kind() == "object" { 59 | // If value is an object, recursively extract its values 60 | extractValues(newPrefix, value, content, result, filePath) 61 | } else if value.Kind() == "string" { 62 | // Find the string_content node inside the string node 63 | var valueText string 64 | if value.NamedChildCount() > 0 && value.NamedChild(0).Kind() == "string_content" { 65 | valueContent := value.NamedChild(0) 66 | valueText = string(valueContent.Utf8Text(content)) 67 | } else { 68 | // Fallback 69 | valueText = string(value.Utf8Text(content)) 70 | valueText = strings.Trim(valueText, "\"") 71 | } 72 | result[newPrefix] = Snippet{ 73 | Key: newPrefix, 74 | Text: valueText, 75 | File: filePath, 76 | Line: int(value.Range().StartPoint.Row) + 1, 77 | } 78 | } else if value.Kind() == "number" || value.Kind() == "true" || value.Kind() == "false" || value.Kind() == "null" { 79 | // For non-string primitive values, convert to string 80 | valueText := string(value.Utf8Text(content)) 81 | result[newPrefix] = Snippet{ 82 | Key: newPrefix, 83 | Text: valueText, 84 | File: filePath, 85 | Line: int(value.Range().StartPoint.Row) + 1, 86 | } 87 | } 88 | } 89 | } 90 | } 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /internal/symfony/route_indexer.go: -------------------------------------------------------------------------------- 1 | package symfony 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/shopware/shopware-lsp/internal/indexer" 8 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 9 | ) 10 | 11 | // Route represents a Symfony route from YAML, PHP, or other sources 12 | type Route struct { 13 | Name string 14 | Path string 15 | Controller string 16 | FilePath string 17 | Line int 18 | } 19 | 20 | type RouteList []Route 21 | 22 | func (rl RouteList) GetByController(name string) *Route { 23 | for _, r := range rl { 24 | if r.Controller == name { 25 | return &r 26 | } 27 | } 28 | return nil 29 | } 30 | 31 | type RouteIndexer struct { 32 | dataIndexer *indexer.DataIndexer[Route] 33 | } 34 | 35 | func NewRouteIndexer(configDir string) (*RouteIndexer, error) { 36 | dataIndexer, err := indexer.NewDataIndexer[Route](filepath.Join(configDir, "route.db")) 37 | if err != nil { 38 | return nil, err 39 | } 40 | return &RouteIndexer{ 41 | dataIndexer: dataIndexer, 42 | }, nil 43 | } 44 | 45 | func (idx *RouteIndexer) ID() string { 46 | return "symfony.route" 47 | } 48 | 49 | func (idx *RouteIndexer) GetRoutes() (RouteList, error) { 50 | return idx.dataIndexer.GetAllValues() 51 | } 52 | 53 | func (idx *RouteIndexer) GetRoute(name string) ([]Route, error) { 54 | return idx.dataIndexer.GetValues(name) 55 | } 56 | 57 | func (idx *RouteIndexer) Index(path string, node *tree_sitter.Node, fileContent []byte) error { 58 | fileExt := strings.ToLower(filepath.Ext(path)) 59 | 60 | switch fileExt { 61 | case ".yml", ".yaml": 62 | return idx.indexYaml(path, node, fileContent) 63 | case ".php": 64 | return idx.indexPhp(path, node, fileContent) 65 | default: 66 | return nil 67 | } 68 | } 69 | 70 | func (idx *RouteIndexer) indexYaml(path string, node *tree_sitter.Node, fileContent []byte) error { 71 | parsedRoutes, err := ParseYAMLRoutes(path, node, fileContent) 72 | if err != nil { 73 | return err 74 | } 75 | 76 | batchSave := make(map[string]map[string]Route) 77 | for _, route := range parsedRoutes { 78 | if _, ok := batchSave[route.FilePath]; !ok { 79 | batchSave[route.FilePath] = make(map[string]Route) 80 | } 81 | batchSave[route.FilePath][route.Name] = route 82 | } 83 | 84 | return idx.dataIndexer.BatchSaveItems(batchSave) 85 | } 86 | 87 | func (idx *RouteIndexer) indexPhp(path string, node *tree_sitter.Node, fileContent []byte) error { 88 | parsedRoutes := parsePHPRoutes(path, node, fileContent) 89 | 90 | batchSave := make(map[string]map[string]Route) 91 | for _, route := range parsedRoutes { 92 | if _, ok := batchSave[route.FilePath]; !ok { 93 | batchSave[route.FilePath] = make(map[string]Route) 94 | } 95 | batchSave[route.FilePath][route.Name] = route 96 | } 97 | 98 | return idx.dataIndexer.BatchSaveItems(batchSave) 99 | } 100 | 101 | func (idx *RouteIndexer) RemovedFiles(paths []string) error { 102 | return idx.dataIndexer.BatchDeleteByFilePaths(paths) 103 | } 104 | 105 | func (idx *RouteIndexer) Close() error { 106 | return idx.dataIndexer.Close() 107 | } 108 | 109 | func (idx *RouteIndexer) Clear() error { 110 | return idx.dataIndexer.Clear() 111 | } 112 | -------------------------------------------------------------------------------- /internal/tree_sitter_helper/twig.go: -------------------------------------------------------------------------------- 1 | package treesitterhelper 2 | 3 | import ( 4 | "strings" 5 | 6 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 7 | ) 8 | 9 | func TwigTransPattern() Pattern { 10 | return And( 11 | NodeKind("string"), 12 | Ancestor( 13 | And( 14 | NodeKind("filter_expression"), 15 | HasChild( 16 | And( 17 | NodeKind("function"), 18 | NodeText("trans"), 19 | ), 20 | ), 21 | ), 22 | 1, 23 | ), 24 | ) 25 | } 26 | 27 | func TwigBlockWithNamePattern(blockName string) Pattern { 28 | return And( 29 | NodeKind("block"), 30 | HasChild( 31 | And( 32 | NodeKind("identifier"), 33 | NodeText(blockName), 34 | ), 35 | ), 36 | ) 37 | } 38 | 39 | func TwigAutocompleteFilterPattern() Pattern { 40 | return Or( 41 | // {{ foo| }} 42 | And( 43 | NodeKind("operator"), 44 | Ancestor(NodeKind("filter_expression"), 1), 45 | ), 46 | 47 | // {{ foo|test }} 48 | And( 49 | NodeKind("function"), 50 | Ancestor(NodeKind("filter_expression"), 1), 51 | ), 52 | ) 53 | } 54 | 55 | func TwigSwIconInPackPattern() Pattern { 56 | return And( 57 | NodeKind("string"), 58 | Ancestor( 59 | And( 60 | NodeKind("pair"), 61 | HasChild( 62 | And( 63 | NodeKind("string"), 64 | NodeText("'pack'"), 65 | ), 66 | ), 67 | Ancestor( 68 | And( 69 | NodeKind("tag"), 70 | HasChild( 71 | And( 72 | NodeKind("keyword"), 73 | NodeText("sw_icon"), 74 | ), 75 | ), 76 | ), 77 | 2, 78 | ), 79 | ), 80 | 1, 81 | ), 82 | ) 83 | } 84 | 85 | // ExtractSwIconObjectToMap extracts the object from a sw_icon tag and converts it to a Go map 86 | func ExtractSwIconObjectToMap(tagNode *tree_sitter.Node, content []byte) map[string]string { 87 | result := make(map[string]string) 88 | 89 | // Find the object node within the tag 90 | var objectNode *tree_sitter.Node 91 | for i := 0; i < int(tagNode.ChildCount()); i++ { 92 | child := tagNode.Child(uint(i)) 93 | if child != nil && child.Kind() == "object" { 94 | objectNode = child 95 | break 96 | } 97 | } 98 | 99 | if objectNode == nil { 100 | return result 101 | } 102 | 103 | // Extract pairs from the object 104 | for i := 0; i < int(objectNode.ChildCount()); i++ { 105 | child := objectNode.Child(uint(i)) 106 | if child != nil && child.Kind() == "pair" { 107 | key, value := extractPairKeyValue(child, content) 108 | if key != "" && value != "" { 109 | result[key] = value 110 | } 111 | } 112 | } 113 | 114 | return result 115 | } 116 | 117 | // extractPairKeyValue extracts key and value from a pair node 118 | func extractPairKeyValue(pairNode *tree_sitter.Node, content []byte) (string, string) { 119 | var key, value string 120 | 121 | for i := 0; i < int(pairNode.ChildCount()); i++ { 122 | child := pairNode.Child(uint(i)) 123 | if child == nil { 124 | continue 125 | } 126 | 127 | switch child.Kind() { 128 | case "string": 129 | text := string(child.Utf8Text(content)) 130 | // Remove quotes from string 131 | text = strings.Trim(text, "'\"") 132 | 133 | if key == "" { 134 | key = text 135 | } else if value == "" { 136 | value = text 137 | } 138 | } 139 | } 140 | 141 | return key, value 142 | } 143 | -------------------------------------------------------------------------------- /vscode-extension/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shopware-lsp", 3 | "displayName": "Shopware Language Server", 4 | "description": "Shopware and Symfony Support for Visual Studio Code", 5 | "version": "0.0.3", 6 | "publisher": "shopware", 7 | "icon": "icon.png", 8 | "galleryBanner": { 9 | "color": "#00ADEF", 10 | "theme": "light" 11 | }, 12 | "engines": { 13 | "vscode": "^1.96.0" 14 | }, 15 | "categories": [ 16 | "Programming Languages", 17 | "Linters" 18 | ], 19 | "keywords": [ 20 | "shopware", 21 | "symfony", 22 | "twig" 23 | ], 24 | "activationEvents": [ 25 | "onLanguage:php", 26 | "onLanguage:xml", 27 | "onLanguage:twig", 28 | "onLanguage:yaml", 29 | "onLanguage:scss" 30 | ], 31 | "repository": { 32 | "type": "git", 33 | "url": "https://github.com/shopwareLabs/shopware-lsp.git" 34 | }, 35 | "bugs": { 36 | "url": "https://github.com/shopwareLabs/shopware-lsp/issues" 37 | }, 38 | "pricing": "Free", 39 | "main": "./dist/extension.js", 40 | "contributes": { 41 | "configuration": { 42 | "title": "Shopware Language Server", 43 | "properties": { 44 | "shopwareLSP.serverPath": { 45 | "type": "string", 46 | "default": "", 47 | "description": "Path to the Shopware Language Server executable. If empty, the extension will try to find the server automatically." 48 | } 49 | } 50 | }, 51 | "commands": [ 52 | { 53 | "command": "shopwareLSP.restart", 54 | "title": "Restart Shopware Language Server" 55 | }, 56 | { 57 | "command": "shopwareLSP.forceReindex", 58 | "title": "Shopware: Force Reindex" 59 | }, 60 | { 61 | "command": "shopware.insertSnippet", 62 | "title": "Shopware: Insert Snippet" 63 | }, 64 | { 65 | "command": "shopware.createSnippetFromSelection", 66 | "title": "Shopware: Create Snippet from Selection" 67 | } 68 | ], 69 | "menus": { 70 | "editor/context": [ 71 | { 72 | "when": "resourceLangId == twig", 73 | "command": "shopware.insertSnippet", 74 | "group": "shopware" 75 | }, 76 | { 77 | "when": "resourceLangId == twig && editorHasSelection", 78 | "command": "shopware.createSnippetFromSelection", 79 | "group": "shopware" 80 | } 81 | ] 82 | } 83 | }, 84 | "scripts": { 85 | "compile": "npm run check-types && node esbuild.js", 86 | "check-types": "tsc --noEmit", 87 | "watch": "npm-run-all -p watch:*", 88 | "watch:esbuild": "node esbuild.js --watch", 89 | "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", 90 | "vscode:prepublish": "npm run package", 91 | "package": "npm run check-types && node esbuild.js --production" 92 | }, 93 | "dependencies": { 94 | "vscode-languageclient": "^8.1.0" 95 | }, 96 | "devDependencies": { 97 | "@types/glob": "^7.2.0", 98 | "@types/mocha": "^10.0.1", 99 | "@types/node": "^16.18.34", 100 | "@types/vscode": "^1.96.0", 101 | "@vscode/test-electron": "^2.3.8", 102 | "esbuild": "^0.25.3", 103 | "glob": "^7.2.3", 104 | "minimatch": "^3.1.2", 105 | "mocha": "^10.2.0", 106 | "typescript": "^5.1.3" 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /internal/theme/theme_indexer.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/shopware/shopware-lsp/internal/indexer" 8 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 9 | tree_sitter_json "github.com/tree-sitter/tree-sitter-json/bindings/go" 10 | ) 11 | 12 | // ThemeConfigIndexer is responsible for indexing theme.json files 13 | type ThemeConfigIndexer struct { 14 | configIndex *indexer.DataIndexer[ThemeConfigField] 15 | } 16 | 17 | // NewThemeConfigIndexer creates a new theme config indexer 18 | func NewThemeConfigIndexer(configDir string) (*ThemeConfigIndexer, error) { 19 | configIndexer, err := indexer.NewDataIndexer[ThemeConfigField](filepath.Join(configDir, "theme_config.db")) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return &ThemeConfigIndexer{ 25 | configIndex: configIndexer, 26 | }, nil 27 | } 28 | 29 | // ID returns the unique identifier for this indexer 30 | func (t *ThemeConfigIndexer) ID() string { 31 | return "theme.indexer" 32 | } 33 | 34 | // Index processes a file and indexes any theme config fields found 35 | func (t *ThemeConfigIndexer) Index(path string, node *tree_sitter.Node, fileContent []byte) error { 36 | // Skip non-theme.json files 37 | if !strings.HasSuffix(path, "theme.json") { 38 | return nil 39 | } 40 | 41 | // Parse the theme.json file 42 | parser := tree_sitter.NewParser() 43 | if err := parser.SetLanguage(tree_sitter.NewLanguage(tree_sitter_json.Language())); err != nil { 44 | return err 45 | } 46 | 47 | tree := parser.Parse(fileContent, nil) 48 | if tree == nil { 49 | return nil 50 | } 51 | defer tree.Close() 52 | 53 | // Extract theme config fields 54 | fields, err := ParseThemeConfig(tree.RootNode(), fileContent, path) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | // Prepare batch save 60 | batchSave := make(map[string]map[string]ThemeConfigField) 61 | 62 | for _, field := range fields { 63 | if _, ok := batchSave[field.Path]; !ok { 64 | batchSave[field.Path] = make(map[string]ThemeConfigField) 65 | } 66 | batchSave[field.Path][field.Key] = field 67 | } 68 | 69 | return t.configIndex.BatchSaveItems(batchSave) 70 | } 71 | 72 | // RemovedFiles handles cleanup when files are removed 73 | func (t *ThemeConfigIndexer) RemovedFiles(paths []string) error { 74 | return t.configIndex.BatchDeleteByFilePaths(paths) 75 | } 76 | 77 | // Close closes the indexer 78 | func (t *ThemeConfigIndexer) Close() error { 79 | return t.configIndex.Close() 80 | } 81 | 82 | // Clear clears all indexed data 83 | func (t *ThemeConfigIndexer) Clear() error { 84 | return t.configIndex.Clear() 85 | } 86 | 87 | // GetThemeConfigFields returns all theme config field keys 88 | func (t *ThemeConfigIndexer) GetThemeConfigFields() ([]string, error) { 89 | return t.configIndex.GetAllKeys() 90 | } 91 | 92 | // GetThemeConfigField returns all fields for a specific key 93 | func (t *ThemeConfigIndexer) GetThemeConfigField(key string) ([]ThemeConfigField, error) { 94 | return t.configIndex.GetValues(key) 95 | } 96 | 97 | // GetAllThemeConfigFields returns all theme config fields 98 | func (t *ThemeConfigIndexer) GetAllThemeConfigFields() ([]ThemeConfigField, error) { 99 | return t.configIndex.GetAllValues() 100 | } 101 | 102 | // IsThemeFile checks if a file is a theme.json file 103 | func IsThemeFile(path string) bool { 104 | return strings.HasSuffix(path, "theme.json") 105 | } 106 | -------------------------------------------------------------------------------- /internal/symfony/php_routes_test.go: -------------------------------------------------------------------------------- 1 | package symfony 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 9 | tree_sitter_php "github.com/tree-sitter/tree-sitter-php/bindings/go" 10 | ) 11 | 12 | func TestExtractRoutesFromFile(t *testing.T) { 13 | // Extract routes from the test file 14 | filePath := "testdata/controller.php" 15 | node, content := parsePHPFile(filePath) 16 | 17 | routes := parsePHPRoutes(filePath, node, content) 18 | 19 | // Verify we found only the method route 20 | assert.Len(t, routes, 1) 21 | 22 | // Verify method route data 23 | expectedRouteMethod := Route{ 24 | Name: "frontend.account.address.create", 25 | Path: "/account/address/create", // Combined path 26 | FilePath: filePath, 27 | Line: 14, // Line number of the Route attribute in the test file 28 | Controller: "App\\Controller\\Frontend\\Account\\AddressController::createAddress", 29 | } 30 | 31 | assert.Equal(t, expectedRouteMethod, routes[0]) 32 | } 33 | 34 | func TestExtractRoutesWithBasePathFromFile(t *testing.T) { 35 | // Extract routes from the test file with base path 36 | filePath := "testdata/controller_base.php" 37 | node, content := parsePHPFile(filePath) 38 | 39 | routes := parsePHPRoutes(filePath, node, content) 40 | 41 | // Verify we found only the method route 42 | assert.Len(t, routes, 1) 43 | 44 | // Verify method route data with combined path 45 | expectedRouteMethod := Route{ 46 | Name: "foo", 47 | Path: "/api/foo", // Base path + route path 48 | FilePath: filePath, 49 | Line: 11, // Line number of the Route attribute in the test file 50 | Controller: "Shopware\\Core\\Api\\ApiController::foo", 51 | } 52 | 53 | assert.Equal(t, expectedRouteMethod, routes[0]) 54 | } 55 | 56 | func TestExtractRoutesStorefrontController(t *testing.T) { 57 | // Extract routes from the test file with base path 58 | filePath := "testdata/wishlist.php" 59 | node, content := parsePHPFile(filePath) 60 | 61 | // Run the actual test 62 | routes := parsePHPRoutes(filePath, node, content) 63 | 64 | // Verify we found routes (should find at least one) 65 | assert.NotEmpty(t, routes) 66 | 67 | // Find the route we're interested in (frontend.wishlist.page) 68 | var wishlistPageRoute *Route 69 | for _, route := range routes { 70 | if route.Name == "frontend.wishlist.page" { 71 | wishlistPageRoute = &route 72 | break 73 | } 74 | } 75 | 76 | // Verify we found the route 77 | assert.NotNil(t, wishlistPageRoute) 78 | 79 | // Verify route data 80 | expectedRouteMethod := Route{ 81 | Name: "frontend.wishlist.page", 82 | Path: "/wishlist", 83 | FilePath: filePath, 84 | Line: 55, // Line number of the Route attribute in the wishlist.php file 85 | Controller: "Shopware\\Storefront\\Controller\\WishlistController::index", 86 | } 87 | 88 | assert.Equal(t, expectedRouteMethod, *wishlistPageRoute) 89 | } 90 | 91 | func parsePHPFile(filePath string) (*tree_sitter.Node, []byte) { 92 | parser := tree_sitter.NewParser() 93 | if err := parser.SetLanguage(tree_sitter.NewLanguage(tree_sitter_php.LanguagePHP())); err != nil { 94 | panic(err) 95 | } 96 | 97 | content, err := os.ReadFile(filePath) 98 | if err != nil { 99 | panic(err) 100 | } 101 | 102 | defer parser.Close() 103 | 104 | tree := parser.Parse(content, nil) 105 | return tree.RootNode(), content 106 | } 107 | -------------------------------------------------------------------------------- /internal/lsp/protocol/diagnostics.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | // DiagnosticParams represents the parameters for a textDocument/diagnostic request 4 | type DiagnosticParams struct { 5 | TextDocument struct { 6 | URI string `json:"uri"` 7 | } `json:"textDocument"` 8 | PreviousResultId string `json:"previousResultId,omitempty"` 9 | } 10 | 11 | // DiagnosticResult represents the result of a textDocument/diagnostic request 12 | type DiagnosticResult struct { 13 | Items []Diagnostic `json:"items"` 14 | ResultId string `json:"resultId,omitempty"` 15 | } 16 | 17 | // DiagnosticSeverity represents the severity of a diagnostic 18 | type DiagnosticSeverity int 19 | 20 | const ( 21 | // DiagnosticSeverityError represents an error diagnostic 22 | DiagnosticSeverityError DiagnosticSeverity = 1 23 | // DiagnosticSeverityWarning represents a warning diagnostic 24 | DiagnosticSeverityWarning DiagnosticSeverity = 2 25 | // DiagnosticSeverityInformation represents an information diagnostic 26 | DiagnosticSeverityInformation DiagnosticSeverity = 3 27 | // DiagnosticSeverityHint represents a hint diagnostic 28 | DiagnosticSeverityHint DiagnosticSeverity = 4 29 | ) 30 | 31 | // DiagnosticTag represents a tag for a diagnostic 32 | type DiagnosticTag int 33 | 34 | const ( 35 | // DiagnosticTagUnnecessary indicates that the code is unnecessary 36 | DiagnosticTagUnnecessary DiagnosticTag = 1 37 | // DiagnosticTagDeprecated indicates that the code is deprecated 38 | DiagnosticTagDeprecated DiagnosticTag = 2 39 | ) 40 | 41 | // Diagnostic represents a diagnostic, such as a compiler error or warning 42 | type Diagnostic struct { 43 | Range Range `json:"range"` 44 | Severity DiagnosticSeverity `json:"severity,omitempty"` 45 | Code interface{} `json:"code,omitempty"` 46 | Source string `json:"source,omitempty"` 47 | Message string `json:"message"` 48 | Tags []DiagnosticTag `json:"tags,omitempty"` 49 | RelatedInformation []DiagnosticRelatedInformation `json:"relatedInformation,omitempty"` 50 | Data interface{} `json:"data,omitempty"` 51 | } 52 | 53 | // DiagnosticRelatedInformation represents additional information related to a diagnostic 54 | type DiagnosticRelatedInformation struct { 55 | Location Location `json:"location"` 56 | Message string `json:"message"` 57 | } 58 | 59 | // PublishDiagnosticsParams represents the parameters for a textDocument/publishDiagnostics notification 60 | type PublishDiagnosticsParams struct { 61 | URI string `json:"uri"` 62 | Version int `json:"version,omitempty"` 63 | Diagnostics []Diagnostic `json:"diagnostics"` 64 | } 65 | 66 | // DocumentDiagnosticReport represents a diagnostic report for a document 67 | type DocumentDiagnosticReport struct { 68 | Kind string `json:"kind"` 69 | ResultId string `json:"resultId,omitempty"` 70 | Items []Diagnostic `json:"items,omitempty"` 71 | } 72 | 73 | // WorkspaceDiagnosticReport represents a diagnostic report for the workspace 74 | type WorkspaceDiagnosticReport struct { 75 | Items []WorkspaceDocumentDiagnosticReport `json:"items"` 76 | } 77 | 78 | // WorkspaceDocumentDiagnosticReport represents a diagnostic report for a document in the workspace 79 | type WorkspaceDocumentDiagnosticReport struct { 80 | URI string `json:"uri"` 81 | Version int `json:"version"` 82 | Report DocumentDiagnosticReport `json:"report"` 83 | } 84 | -------------------------------------------------------------------------------- /internal/lsp/completion/systemconfig_completion.go: -------------------------------------------------------------------------------- 1 | package completion 2 | 3 | import ( 4 | "context" 5 | "path/filepath" 6 | "slices" 7 | 8 | "github.com/shopware/shopware-lsp/internal/lsp" 9 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 10 | "github.com/shopware/shopware-lsp/internal/php" 11 | "github.com/shopware/shopware-lsp/internal/systemconfig" 12 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 13 | ) 14 | 15 | type SystemConfigCompletionProvider struct { 16 | indexer *systemconfig.SystemConfigIndexer 17 | phpIndex *php.PHPIndex 18 | } 19 | 20 | func (s *SystemConfigCompletionProvider) GetCompletions(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 21 | if params.Node == nil { 22 | return nil 23 | } 24 | 25 | if treesitterhelper.TwigStringInFunctionPattern("config").Matches(params.Node, params.DocumentContent) { 26 | completions, err := s.indexer.GetAllSystemConfigEntries() 27 | if err != nil { 28 | return nil 29 | } 30 | 31 | var completionItems []protocol.CompletionItem 32 | for _, completion := range completions { 33 | completionItems = append(completionItems, protocol.CompletionItem{ 34 | Label: completion.Name, 35 | }) 36 | } 37 | 38 | return completionItems 39 | } 40 | 41 | if filepath.Ext(params.TextDocument.URI) == ".php" { 42 | return s.phpCompletion(ctx, params) 43 | } 44 | 45 | return nil 46 | } 47 | 48 | func (s *SystemConfigCompletionProvider) phpCompletion(ctx context.Context, params *protocol.CompletionParams) []protocol.CompletionItem { 49 | if s.phpIndex.IsMethodCalledOnClass(ctx, params.Node, params.DocumentContent, "Shopware\\Core\\System\\SystemConfig\\SystemConfigService") { 50 | if s.phpIndex.IsMethodCalledName(ctx, params.Node, params.DocumentContent, "get", "getInt", "getString", "getFloat", "getBool", "set") { 51 | completions, err := s.indexer.GetAllSystemConfigEntries() 52 | if err != nil { 53 | return nil 54 | } 55 | 56 | var completionItems []protocol.CompletionItem 57 | for _, completion := range completions { 58 | completionItems = append(completionItems, protocol.CompletionItem{ 59 | Label: completion.Name, 60 | }) 61 | } 62 | 63 | return completionItems 64 | } 65 | 66 | if s.phpIndex.IsMethodCalledName(ctx, params.Node, params.DocumentContent, "getDomain") { 67 | completions, err := s.indexer.GetAllSystemConfigEntries() 68 | if err != nil { 69 | return nil 70 | } 71 | 72 | var uniqueDomains []string 73 | for _, completion := range completions { 74 | if !slices.Contains(uniqueDomains, completion.Namespace) { 75 | uniqueDomains = append(uniqueDomains, completion.Namespace) 76 | } 77 | } 78 | 79 | var completionItems []protocol.CompletionItem 80 | for _, domain := range uniqueDomains { 81 | completionItems = append(completionItems, protocol.CompletionItem{ 82 | Label: domain, 83 | }) 84 | } 85 | 86 | return completionItems 87 | } 88 | } 89 | 90 | return nil 91 | } 92 | 93 | func (s *SystemConfigCompletionProvider) GetTriggerCharacters() []string { 94 | return []string{} 95 | } 96 | 97 | func NewSystemConfigCompletion(lspServer *lsp.Server) *SystemConfigCompletionProvider { 98 | indexer, _ := lspServer.GetIndexer("systemconfig.indexer") 99 | phpIndexer, _ := lspServer.GetIndexer("php.index") 100 | return &SystemConfigCompletionProvider{ 101 | indexer: indexer.(*systemconfig.SystemConfigIndexer), 102 | phpIndex: phpIndexer.(*php.PHPIndex), 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /internal/lsp/hover/twig_hover.go: -------------------------------------------------------------------------------- 1 | package hover 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/shopware/shopware-lsp/internal/extension" 10 | "github.com/shopware/shopware-lsp/internal/lsp" 11 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 12 | "github.com/shopware/shopware-lsp/internal/theme" 13 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 14 | ) 15 | 16 | type TwigHoverProvider struct { 17 | iconProvider *theme.IconProvider 18 | projectRoot string 19 | } 20 | 21 | func NewTwigHoverProvider(projectRoot string, lspServer *lsp.Server) *TwigHoverProvider { 22 | extensionIndexer, _ := lspServer.GetIndexer("extension.indexer") 23 | 24 | iconProvider := theme.NewIconProvider(projectRoot, extensionIndexer.(*extension.ExtensionIndexer)) 25 | 26 | return &TwigHoverProvider{ 27 | iconProvider: iconProvider, 28 | projectRoot: projectRoot, 29 | } 30 | } 31 | 32 | func (p *TwigHoverProvider) GetHover(ctx context.Context, params *protocol.HoverParams) (*protocol.Hover, error) { 33 | if params.Node == nil { 34 | return nil, nil 35 | } 36 | 37 | // Only process .twig files 38 | if !strings.HasSuffix(strings.ToLower(params.TextDocument.URI), ".twig") { 39 | return nil, nil 40 | } 41 | 42 | // Check if hovering over sw_icon 43 | if treesitterhelper.TwigStringInTagPattern("sw_icon").Matches(params.Node, []byte(params.DocumentContent)) { 44 | iconName := treesitterhelper.GetNodeText(params.Node, params.DocumentContent) 45 | 46 | // Extract icon configuration from parent node 47 | cfg := treesitterhelper.ExtractSwIconObjectToMap(params.Node.Parent(), params.DocumentContent) 48 | 49 | pack, ok := cfg["pack"] 50 | if !ok { 51 | pack = "default" 52 | } 53 | 54 | // Get the icon path 55 | iconPath := p.iconProvider.GetIcon(pack, iconName) 56 | 57 | if iconPath != "" { 58 | // Create markdown content with icon preview 59 | // For VSCode and other editors, we need to use file:// URIs for local images 60 | var imageUri string 61 | if strings.HasPrefix(iconPath, "/") { 62 | // Absolute path - convert to file URI 63 | imageUri = fmt.Sprintf("file://%s", iconPath) 64 | } else { 65 | // Try to create a relative path from the current document 66 | docDir := filepath.Dir(strings.TrimPrefix(params.TextDocument.URI, "file://")) 67 | relPath, err := filepath.Rel(docDir, iconPath) 68 | if err != nil { 69 | // If relative path fails, use absolute file URI 70 | imageUri = fmt.Sprintf("file://%s", iconPath) 71 | } else { 72 | imageUri = relPath 73 | } 74 | } 75 | 76 | // Make display path relative to project root 77 | displayPath, err := filepath.Rel(p.projectRoot, iconPath) 78 | if err != nil { 79 | // If we can't make it relative, use the original path 80 | displayPath = iconPath 81 | } 82 | 83 | markdownContent := fmt.Sprintf("**Icon**: `%s`\n\n**Pack**: `%s`\n\n**Preview**:\n\n![%s](%s)\n\n**Path**: `%s`", 84 | iconName, 85 | pack, 86 | iconName, 87 | imageUri, 88 | displayPath, 89 | ) 90 | 91 | return &protocol.Hover{ 92 | Contents: protocol.MarkupContent{ 93 | Kind: protocol.Markdown, 94 | Value: markdownContent, 95 | }, 96 | Range: &protocol.Range{ 97 | Start: protocol.Position{ 98 | Line: params.Position.Line, 99 | Character: params.Position.Character, 100 | }, 101 | End: protocol.Position{ 102 | Line: params.Position.Line, 103 | Character: params.Position.Character + len(iconName), 104 | }, 105 | }, 106 | }, nil 107 | } 108 | } 109 | 110 | return nil, nil 111 | } -------------------------------------------------------------------------------- /internal/tree_sitter_helper/yaml.go: -------------------------------------------------------------------------------- 1 | package treesitterhelper 2 | 3 | import ( 4 | "strings" 5 | 6 | tree_sitter "github.com/tree-sitter/go-tree-sitter" 7 | ) 8 | 9 | var isServicesNode = And( 10 | NodeKind("block_mapping_pair"), 11 | HasChild( 12 | And( 13 | NodeKind("flow_node"), 14 | HasChild( 15 | And( 16 | NodeKind("plain_scalar"), 17 | HasChild( 18 | And( 19 | NodeKind("string_scalar"), 20 | NodeText("services"), 21 | ), 22 | ), 23 | ), 24 | ), 25 | ), 26 | ), 27 | ) 28 | 29 | // GetYAMLValue extracts the scalar value from a YAML node 30 | func GetYAMLValue(node *tree_sitter.Node, source []byte) string { 31 | if node == nil { 32 | return "" 33 | } 34 | 35 | // Handle different node types 36 | if node.Kind() == "flow_node" { 37 | // Extract value and remove quotes if present 38 | value := string(node.Utf8Text(source)) 39 | return strings.Trim(value, "\"'") 40 | } else if node.Kind() == "block_scalar" { 41 | // For multiline values 42 | return string(node.Utf8Text(source)) 43 | } else if node.Kind() == "string_scalar" { 44 | return string(node.Utf8Text(source)) 45 | } else if node.Kind() == "single_quote_scalar" { 46 | return strings.Trim(node.Utf8Text(source), "\"'") 47 | } 48 | 49 | return "" 50 | } 51 | 52 | func IsYamlServiceId(node *tree_sitter.Node, source []byte) bool { 53 | return Or( 54 | And( 55 | NodeKind("block_mapping_pair"), 56 | Ancestor( 57 | isServicesNode, 58 | 3, 59 | ), 60 | ), 61 | And( 62 | NodeKind("string_scalar"), 63 | Ancestor( 64 | And( 65 | NodeKind("flow_node"), 66 | NodeName("key"), 67 | Ancestor( 68 | isServicesNode, 69 | 7, 70 | ), 71 | ), 72 | 2, 73 | ), 74 | ), 75 | ).Matches(node, source) 76 | } 77 | 78 | func IsYamlArgumentServiceId(node *tree_sitter.Node, source []byte) bool { 79 | return And( 80 | NodeKind("single_quote_scalar"), 81 | Ancestor( 82 | And( 83 | NodeKind("block_node"), 84 | Ancestor( 85 | And( 86 | NodeKind("block_mapping_pair"), 87 | HasChild( 88 | And( 89 | NodeKind("flow_node"), 90 | HasChild( 91 | And( 92 | NodeKind("plain_scalar"), 93 | HasChild( 94 | And( 95 | NodeKind("string_scalar"), 96 | NodeText("arguments"), 97 | ), 98 | ), 99 | ), 100 | ), 101 | ), 102 | ), 103 | Ancestor( 104 | And( 105 | NodeKind("block_mapping_pair"), 106 | Ancestor( 107 | isServicesNode, 108 | 3, 109 | ), 110 | ), 111 | 3, 112 | ), 113 | ), 114 | 1, 115 | ), 116 | ), 117 | 4, 118 | ), 119 | ).Matches(node, source) 120 | } 121 | 122 | func IsYamlClassPropertyInService() Pattern { 123 | return And( 124 | NodeKind("string_scalar"), 125 | Ancestor( 126 | IsYamlClassPropertyInServiceToType(), 127 | 4, 128 | ), 129 | ) 130 | } 131 | 132 | func IsYamlClassPropertyInServiceToType() Pattern { 133 | return And( 134 | NodeKind("block_mapping"), 135 | HasChild( 136 | And( 137 | NodeKind("block_mapping_pair"), 138 | HasChild( 139 | And( 140 | NodeKind("flow_node"), 141 | NodeName("key"), 142 | HasChild( 143 | And( 144 | NodeKind("plain_scalar"), 145 | NodeText("class"), 146 | ), 147 | ), 148 | ), 149 | ), 150 | Ancestor( 151 | And( 152 | NodeKind("block_mapping_pair"), 153 | Ancestor( 154 | isServicesNode, 155 | 3, 156 | ), 157 | ), 158 | 3, 159 | ), 160 | ), 161 | ), 162 | ) 163 | } 164 | -------------------------------------------------------------------------------- /internal/lsp/codeaction/snippet_codeaction.go: -------------------------------------------------------------------------------- 1 | package codeaction 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "path/filepath" 7 | "strings" 8 | 9 | "github.com/shopware/shopware-lsp/internal/lsp" 10 | "github.com/shopware/shopware-lsp/internal/lsp/protocol" 11 | "github.com/shopware/shopware-lsp/internal/snippet" 12 | treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper" 13 | ) 14 | 15 | // SnippetCodeActionProvider provides code actions for snippet diagnostics 16 | type SnippetCodeActionProvider struct { 17 | snippetIndex *snippet.SnippetIndexer 18 | } 19 | 20 | // NewSnippetCodeActionProvider creates a new SnippetCodeActionProvider 21 | func NewSnippetCodeActionProvider(lspServer *lsp.Server) *SnippetCodeActionProvider { 22 | snippetIndexer, ok := lspServer.GetIndexer("snippet.indexer") 23 | if !ok { 24 | return &SnippetCodeActionProvider{} 25 | } 26 | return &SnippetCodeActionProvider{ 27 | snippetIndex: snippetIndexer.(*snippet.SnippetIndexer), 28 | } 29 | } 30 | 31 | // GetCodeActionKinds returns the kinds of code actions this provider can provide 32 | func (s *SnippetCodeActionProvider) GetCodeActionKinds() []protocol.CodeActionKind { 33 | return []protocol.CodeActionKind{ 34 | protocol.CodeActionQuickFix, 35 | } 36 | } 37 | 38 | // GetCodeActions returns code actions for snippet diagnostics 39 | func (s *SnippetCodeActionProvider) GetCodeActions(ctx context.Context, params *protocol.CodeActionParams) []protocol.CodeAction { 40 | if !strings.HasSuffix(strings.ToLower(filepath.Ext(params.TextDocument.URI)), ".twig") { 41 | return []protocol.CodeAction{} 42 | } 43 | 44 | var codeActions []protocol.CodeAction 45 | 46 | if params.Range.Start.Line == params.Range.End.Line && params.Range.Start.Character == params.Range.End.Character { 47 | // No selection, so we can't create a snippet from selection 48 | codeActions = append(codeActions, protocol.CodeAction{ 49 | Title: "Insert Snippet", 50 | Kind: protocol.CodeActionQuickFix, 51 | Command: &protocol.CommandAction{ 52 | Title: "Insert Snippet", 53 | Command: "shopware.insertSnippet", 54 | }, 55 | }) 56 | } 57 | 58 | if params.Range.Start.Line != params.Range.End.Line || params.Range.Start.Character != params.Range.End.Character { 59 | // There is a text selection 60 | selectedText := treesitterhelper.GetTextForRange(params.DocumentContent, params.Range) 61 | if selectedText != "" { 62 | codeActions = append(codeActions, protocol.CodeAction{ 63 | Title: "Create snippet from selection", 64 | Kind: protocol.CodeActionQuickFix, 65 | Command: &protocol.CommandAction{ 66 | Title: "Create Snippet from Selection", 67 | Command: "shopware.createSnippetFromSelection", 68 | Arguments: []any{params.TextDocument.URI, selectedText}, 69 | }, 70 | }) 71 | } 72 | } 73 | 74 | // Process only snippet-related diagnostics 75 | for _, diagnostic := range params.Context.Diagnostics { 76 | if diagnostic.Code != "frontend.snippet.missing" { 77 | continue 78 | } 79 | 80 | data := diagnostic.Data.(map[string]interface{}) 81 | 82 | snippetKey := data["snippetText"].(string) 83 | 84 | // Create command-based code action 85 | commandAction := protocol.CodeAction{ 86 | Title: fmt.Sprintf("Create snippet %s", snippetKey), 87 | Kind: protocol.CodeActionQuickFix, 88 | Diagnostics: []protocol.Diagnostic{ 89 | diagnostic, 90 | }, 91 | Command: &protocol.CommandAction{ 92 | Title: "Create Snippet", 93 | Command: "shopware.createSnippet", 94 | Arguments: []interface{}{snippetKey, params.TextDocument.URI}, 95 | }, 96 | } 97 | 98 | codeActions = append(codeActions, commandAction) 99 | } 100 | 101 | return codeActions 102 | } 103 | -------------------------------------------------------------------------------- /internal/php/indexer_advanced_test.go: -------------------------------------------------------------------------------- 1 | package php 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAdvancedNamespaceAliases(t *testing.T) { 10 | // Create a new context for the test 11 | index, err := NewPHPIndex(t.TempDir()) 12 | assert.NoError(t, err) 13 | 14 | // Parse the test file with advanced namespace aliases 15 | classes := index.GetClassesOfFile("testdata/04.php") 16 | 17 | // Verify we found the class 18 | assert.Len(t, classes, 1) 19 | 20 | expectedClassName := "Shopware\\Core\\Content\\Product\\Advanced\\AdvancedProductTest" 21 | assert.Contains(t, classes, expectedClassName) 22 | 23 | // Verify the class properties 24 | assert.Equal(t, expectedClassName, classes[expectedClassName].Name) 25 | assert.Equal(t, "testdata/04.php", classes[expectedClassName].Path) 26 | 27 | // Verify the properties with aliased types are correctly resolved 28 | properties := classes[expectedClassName].Properties 29 | assert.Len(t, properties, 6) 30 | 31 | // Check property with group use statement type (Request) 32 | assert.Contains(t, properties, "request") 33 | assert.Equal(t, "request", properties["request"].Name) 34 | assert.Equal(t, Private, properties["request"].Visibility) 35 | assert.Equal(t, "Symfony\\Component\\HttpFoundation\\Request", properties["request"].Type.Name()) 36 | 37 | // Check property with group use statement type (Response) 38 | assert.Contains(t, properties, "response") 39 | assert.Equal(t, "response", properties["response"].Name) 40 | assert.Equal(t, Private, properties["response"].Visibility) 41 | assert.Equal(t, "Symfony\\Component\\HttpFoundation\\Response", properties["response"].Type.Name()) 42 | 43 | // Check property with group use statement type (Kernel) 44 | assert.Contains(t, properties, "kernel") 45 | assert.Equal(t, "kernel", properties["kernel"].Name) 46 | assert.Equal(t, Private, properties["kernel"].Visibility) 47 | assert.Equal(t, "Symfony\\Component\\HttpKernel\\Kernel", properties["kernel"].Type.Name()) 48 | 49 | // Check property with aliased type (DbConnection) 50 | assert.Contains(t, properties, "connection") 51 | assert.Equal(t, "connection", properties["connection"].Name) 52 | assert.Equal(t, Private, properties["connection"].Visibility) 53 | assert.Equal(t, "Doctrine\\DBAL\\Connection", properties["connection"].Type.Name()) 54 | 55 | // Check property with aliased type (Repository) 56 | assert.Contains(t, properties, "productRepository") 57 | assert.Equal(t, "productRepository", properties["productRepository"].Name) 58 | assert.Equal(t, Private, properties["productRepository"].Visibility) 59 | assert.Equal(t, "Shopware\\Core\\Framework\\DataAbstractionLayer\\EntityRepository", properties["productRepository"].Type.Name()) 60 | 61 | // Verify the methods 62 | methods := classes[expectedClassName].Methods 63 | assert.Len(t, methods, 4) 64 | 65 | // Check method with union type return (PHP 8.0+) 66 | assert.Contains(t, methods, "getRequestOrResponse") 67 | assert.Equal(t, "getRequestOrResponse", methods["getRequestOrResponse"].Name) 68 | assert.Equal(t, Public, methods["getRequestOrResponse"].Visibility) 69 | // Note: Current implementation might not handle union types correctly yet 70 | 71 | // Check method with nullable type 72 | assert.Contains(t, methods, "getOptionalKernel") 73 | assert.Equal(t, "getOptionalKernel", methods["getOptionalKernel"].Name) 74 | assert.Equal(t, Public, methods["getOptionalKernel"].Visibility) 75 | // Note: Current implementation might not handle nullable types correctly yet 76 | 77 | // Check method with mixed return type 78 | assert.Contains(t, methods, "getData") 79 | assert.Equal(t, "getData", methods["getData"].Name) 80 | assert.Equal(t, Public, methods["getData"].Visibility) 81 | assert.Equal(t, "mixed", methods["getData"].ReturnType.Name()) 82 | } 83 | -------------------------------------------------------------------------------- /internal/lsp/protocol/codeaction.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | import tree_sitter "github.com/tree-sitter/go-tree-sitter" 4 | 5 | // CodeActionParams represents the parameters for a textDocument/codeAction request 6 | type CodeActionParams struct { 7 | TextDocument struct { 8 | URI string `json:"uri"` 9 | } `json:"textDocument"` 10 | Range Range `json:"range"` 11 | Context CodeActionContext `json:"context"` 12 | 13 | Node *tree_sitter.Node `json:"-"` 14 | DocumentContent []byte `json:"-"` 15 | } 16 | 17 | // CodeActionContext represents the context for a code action request 18 | type CodeActionContext struct { 19 | Diagnostics []Diagnostic `json:"diagnostics"` 20 | Only []string `json:"only,omitempty"` 21 | } 22 | 23 | // CodeActionKind represents the kind of a code action 24 | type CodeActionKind string 25 | 26 | const ( 27 | // CodeActionQuickFix represents a quick fix action 28 | CodeActionQuickFix CodeActionKind = "quickfix" 29 | // CodeActionRefactor represents a refactoring action 30 | CodeActionRefactor CodeActionKind = "refactor" 31 | // CodeActionRefactorExtract represents an extract refactoring action 32 | CodeActionRefactorExtract CodeActionKind = "refactor.extract" 33 | // CodeActionRefactorInline represents an inline refactoring action 34 | CodeActionRefactorInline CodeActionKind = "refactor.inline" 35 | // CodeActionRefactorRewrite represents a rewrite refactoring action 36 | CodeActionRefactorRewrite CodeActionKind = "refactor.rewrite" 37 | // CodeActionSource represents a source action 38 | CodeActionSource CodeActionKind = "source" 39 | // CodeActionSourceOrganizeImports represents an organize imports action 40 | CodeActionSourceOrganizeImports CodeActionKind = "source.organizeImports" 41 | ) 42 | 43 | // CodeAction represents a code action 44 | type CodeAction struct { 45 | Title string `json:"title"` 46 | Kind CodeActionKind `json:"kind,omitempty"` 47 | Diagnostics []Diagnostic `json:"diagnostics,omitempty"` 48 | Edit *WorkspaceEdit `json:"edit,omitempty"` 49 | Command *CommandAction `json:"command,omitempty"` 50 | Data interface{} `json:"data,omitempty"` 51 | } 52 | 53 | // CommandAction represents a command to be executed 54 | type CommandAction struct { 55 | Title string `json:"title"` 56 | Command string `json:"command"` 57 | Arguments []interface{} `json:"arguments,omitempty"` 58 | } 59 | 60 | // TextEdit represents a text edit operation 61 | type TextEdit struct { 62 | Range Range `json:"range"` 63 | NewText string `json:"newText"` 64 | } 65 | 66 | // WorkspaceEdit represents a workspace edit operation 67 | type WorkspaceEdit struct { 68 | Changes map[string][]TextEdit `json:"changes,omitempty"` 69 | DocumentChanges []DocumentChange `json:"documentChanges,omitempty"` 70 | ChangeAnnotations map[string]ChangeAnnotation `json:"changeAnnotations,omitempty"` 71 | } 72 | 73 | // DocumentChange represents a change to a document 74 | type DocumentChange struct { 75 | TextDocument OptionalVersionedTextDocumentIdentifier `json:"textDocument"` 76 | Edits []TextEdit `json:"edits"` 77 | AnnotationID string `json:"annotationId,omitempty"` 78 | } 79 | 80 | // ChangeAnnotation represents an annotation for a change 81 | type ChangeAnnotation struct { 82 | Label string `json:"label"` 83 | NeedsConfirmation bool `json:"needsConfirmation,omitempty"` 84 | Description string `json:"description,omitempty"` 85 | } 86 | 87 | // OptionalVersionedTextDocumentIdentifier represents a text document identifier with an optional version 88 | type OptionalVersionedTextDocumentIdentifier struct { 89 | URI string `json:"uri"` 90 | Version *int `json:"version,omitempty"` 91 | } 92 | -------------------------------------------------------------------------------- /internal/lsp/protocol/file_sync.go: -------------------------------------------------------------------------------- 1 | package protocol 2 | 3 | // FileOperationRegistrationOptions represents the options for registering file operations 4 | type FileOperationRegistrationOptions struct { 5 | Filters []FileOperationFilter `json:"filters"` 6 | } 7 | 8 | // FileOperationFilter represents a filter for file operations 9 | type FileOperationFilter struct { 10 | Scheme string `json:"scheme,omitempty"` 11 | Pattern FileOperationPattern `json:"pattern"` 12 | } 13 | 14 | // FileOperationPattern represents a pattern for file operations 15 | type FileOperationPattern struct { 16 | Glob string `json:"glob"` 17 | Matches string `json:"matches,omitempty"` 18 | Options struct { 19 | IgnoreCase bool `json:"ignoreCase,omitempty"` 20 | } `json:"options,omitempty"` 21 | } 22 | 23 | // FileEvent represents a file event 24 | type FileEvent struct { 25 | URI string `json:"uri"` 26 | Type int `json:"type"` 27 | } 28 | 29 | // FileChangeType represents the type of file change 30 | type FileChangeType int 31 | 32 | const ( 33 | // FileCreated represents a file creation event 34 | FileCreated FileChangeType = 1 35 | // FileChanged represents a file change event 36 | FileChanged FileChangeType = 2 37 | // FileDeleted represents a file deletion event 38 | FileDeleted FileChangeType = 3 39 | ) 40 | 41 | // DidChangeWatchedFilesParams represents the parameters for a didChangeWatchedFiles notification 42 | type DidChangeWatchedFilesParams struct { 43 | Changes []FileEvent `json:"changes"` 44 | } 45 | 46 | // DidChangeWatchedFilesRegistrationOptions represents the options for registering file watchers 47 | type DidChangeWatchedFilesRegistrationOptions struct { 48 | Watchers []FileSystemWatcher `json:"watchers"` 49 | } 50 | 51 | // FileSystemWatcher represents a file system watcher 52 | type FileSystemWatcher struct { 53 | GlobPattern string `json:"globPattern"` 54 | Kind int `json:"kind,omitempty"` 55 | } 56 | 57 | // WatchKind represents the kind of file watching 58 | type WatchKind int 59 | 60 | const ( 61 | // WatchCreate represents watching for file creation 62 | WatchCreate WatchKind = 1 63 | // WatchChange represents watching for file changes 64 | WatchChange WatchKind = 2 65 | // WatchDelete represents watching for file deletion 66 | WatchDelete WatchKind = 4 67 | ) 68 | 69 | // FileOperationOptions represents the options for file operations 70 | type FileOperationOptions struct { 71 | DidCreate *FileOperationRegistrationOptions `json:"didCreate,omitempty"` 72 | WillCreate *FileOperationRegistrationOptions `json:"willCreate,omitempty"` 73 | DidRename *FileOperationRegistrationOptions `json:"didRename,omitempty"` 74 | WillRename *FileOperationRegistrationOptions `json:"willRename,omitempty"` 75 | DidDelete *FileOperationRegistrationOptions `json:"didDelete,omitempty"` 76 | WillDelete *FileOperationRegistrationOptions `json:"willDelete,omitempty"` 77 | } 78 | 79 | // CreateFilesParams represents the parameters for a workspace/willCreateFiles request 80 | type CreateFilesParams struct { 81 | Files []FileCreate `json:"files"` 82 | } 83 | 84 | // FileCreate represents a file creation operation 85 | type FileCreate struct { 86 | URI string `json:"uri"` 87 | } 88 | 89 | // RenameFilesParams represents the parameters for a workspace/willRenameFiles request 90 | type RenameFilesParams struct { 91 | Files []FileRename `json:"files"` 92 | } 93 | 94 | // FileRename represents a file rename operation 95 | type FileRename struct { 96 | OldURI string `json:"oldUri"` 97 | NewURI string `json:"newUri"` 98 | } 99 | 100 | // DeleteFilesParams represents the parameters for a workspace/willDeleteFiles request 101 | type DeleteFilesParams struct { 102 | Files []FileDelete `json:"files"` 103 | } 104 | 105 | // FileDelete represents a file deletion operation 106 | type FileDelete struct { 107 | URI string `json:"uri"` 108 | } 109 | --------------------------------------------------------------------------------