Skip to content

Commit bb97295

Browse files
committed
feat: add hover support for snippet translations and implement tests
1 parent 63fb6d0 commit bb97295

File tree

4 files changed

+389
-0
lines changed

4 files changed

+389
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ A Language Server Protocol (LSP) implementation for Shopware development.
2929
- Diagnostics for missing snippets in Twig templates
3030
- Quick Fix to add missing snippets
3131
- Go-to-definition for snippet keys
32+
- Hover support showing all available translations for a snippet key
3233

3334
### Route Support
3435
- Route name completion in PHP (`redirectToRoute` method) and Twig files (`seoUrl`, `url`, `path` functions)
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package hover
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"path/filepath"
7+
"sort"
8+
"strings"
9+
10+
"github.com/shopware/shopware-lsp/internal/lsp"
11+
"github.com/shopware/shopware-lsp/internal/lsp/protocol"
12+
"github.com/shopware/shopware-lsp/internal/snippet"
13+
treesitterhelper "github.com/shopware/shopware-lsp/internal/tree_sitter_helper"
14+
)
15+
16+
type SnippetHoverProvider struct {
17+
snippetIndexer *snippet.SnippetIndexer
18+
projectRoot string
19+
}
20+
21+
func NewSnippetHoverProvider(projectRoot string, lspServer *lsp.Server) *SnippetHoverProvider {
22+
snippetIndexer, _ := lspServer.GetIndexer("snippet.indexer")
23+
return &SnippetHoverProvider{
24+
snippetIndexer: snippetIndexer.(*snippet.SnippetIndexer),
25+
projectRoot: projectRoot,
26+
}
27+
}
28+
29+
func (p *SnippetHoverProvider) GetHover(ctx context.Context, params *protocol.HoverParams) (*protocol.Hover, error) {
30+
if params.Node == nil {
31+
return nil, nil
32+
}
33+
34+
// Handle both .twig and .php files
35+
switch strings.ToLower(filepath.Ext(params.TextDocument.URI)) {
36+
case ".twig":
37+
return p.twigHover(ctx, params)
38+
case ".php":
39+
return p.phpHover(ctx, params)
40+
default:
41+
return nil, nil
42+
}
43+
}
44+
45+
func (p *SnippetHoverProvider) twigHover(_ context.Context, params *protocol.HoverParams) (*protocol.Hover, error) {
46+
if treesitterhelper.TwigTransPattern().Matches(params.Node, params.DocumentContent) {
47+
snippetKey := treesitterhelper.GetNodeText(params.Node, params.DocumentContent)
48+
return p.createHoverForSnippet(snippetKey, params)
49+
}
50+
return nil, nil
51+
}
52+
53+
func (p *SnippetHoverProvider) phpHover(_ context.Context, params *protocol.HoverParams) (*protocol.Hover, error) {
54+
if treesitterhelper.IsPHPThisMethodCall("trans").Matches(params.Node, params.DocumentContent) {
55+
snippetKey := treesitterhelper.GetNodeText(params.Node, params.DocumentContent)
56+
return p.createHoverForSnippet(snippetKey, params)
57+
}
58+
return nil, nil
59+
}
60+
61+
func (p *SnippetHoverProvider) createHoverForSnippet(snippetKey string, params *protocol.HoverParams) (*protocol.Hover, error) {
62+
snippets, err := p.snippetIndexer.GetFrontendSnippet(snippetKey)
63+
if err != nil || len(snippets) == 0 {
64+
return nil, nil
65+
}
66+
67+
// Sort snippets by file path for consistent display
68+
sort.Slice(snippets, func(i, j int) bool {
69+
return snippets[i].File < snippets[j].File
70+
})
71+
72+
// Build markdown content showing all translations
73+
var markdownContent strings.Builder
74+
markdownContent.WriteString(fmt.Sprintf("**Snippet**: `%s`\n\n", snippetKey))
75+
markdownContent.WriteString("**Translations**:\n\n")
76+
77+
for _, snippet := range snippets {
78+
// Extract locale from file path (e.g., "de-DE" or "en-GB" from the path)
79+
locale := extractLocaleFromPath(snippet.File)
80+
81+
// Make path relative to project root
82+
displayPath, err := filepath.Rel(p.projectRoot, snippet.File)
83+
if err != nil {
84+
displayPath = snippet.File
85+
}
86+
87+
// Format the translation entry
88+
markdownContent.WriteString(fmt.Sprintf("- **%s**: `%s`\n", locale, snippet.Text))
89+
markdownContent.WriteString(fmt.Sprintf(" <small>%s:%d</small>\n\n", displayPath, snippet.Line))
90+
}
91+
92+
return &protocol.Hover{
93+
Contents: protocol.MarkupContent{
94+
Kind: protocol.Markdown,
95+
Value: markdownContent.String(),
96+
},
97+
Range: &protocol.Range{
98+
Start: protocol.Position{
99+
Line: params.Position.Line,
100+
Character: params.Position.Character,
101+
},
102+
End: protocol.Position{
103+
Line: params.Position.Line,
104+
Character: params.Position.Character + len(snippetKey),
105+
},
106+
},
107+
}, nil
108+
}
109+
110+
// extractLocaleFromPath tries to extract the locale from the file path
111+
// e.g., "/path/to/Resources/snippet/de-DE/snippet.json" -> "de-DE"
112+
// e.g., "/path/to/Resources/snippet/de_DE/storefront.de-DE.json" -> "de-DE"
113+
func extractLocaleFromPath(path string) string {
114+
// Normalize path separators to forward slashes for consistent handling
115+
// Handle both Unix and Windows path separators
116+
normalizedPath := strings.ReplaceAll(path, "\\", "/")
117+
118+
// First, try to extract from filename (e.g., "storefront.de-DE.json")
119+
parts := strings.Split(normalizedPath, "/")
120+
if len(parts) > 0 {
121+
filename := parts[len(parts)-1]
122+
if strings.Contains(filename, ".") {
123+
filenameParts := strings.Split(filename, ".")
124+
for _, part := range filenameParts {
125+
if isLocalePattern(part) {
126+
return normalizeLocale(part)
127+
}
128+
}
129+
}
130+
}
131+
132+
// Then, try to extract from directory structure
133+
for i, part := range parts {
134+
// Check if this part looks like a locale
135+
if isLocalePattern(part) {
136+
return normalizeLocale(part)
137+
}
138+
// Also check if we're in a snippet directory
139+
if part == "snippet" && i+1 < len(parts) {
140+
// The next part might be the locale
141+
nextPart := parts[i+1]
142+
if isLocalePattern(nextPart) {
143+
return normalizeLocale(nextPart)
144+
}
145+
}
146+
}
147+
148+
return "unknown"
149+
}
150+
151+
// isLocalePattern checks if a string matches common locale patterns
152+
func isLocalePattern(s string) bool {
153+
// Check for patterns like "de-DE", "en-GB", "de_DE", "en_GB"
154+
if len(s) == 5 && (s[2] == '-' || s[2] == '_') {
155+
return true
156+
}
157+
// Check for patterns like "de", "en", "fr"
158+
if len(s) == 2 {
159+
return true
160+
}
161+
return false
162+
}
163+
164+
// normalizeLocale converts locale to standard format (e.g., "de_DE" -> "de-DE")
165+
func normalizeLocale(locale string) string {
166+
return strings.ReplaceAll(locale, "_", "-")
167+
}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
package hover
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
)
8+
9+
func TestExtractLocaleFromPath(t *testing.T) {
10+
tests := []struct {
11+
name string
12+
path string
13+
expected string
14+
}{
15+
{
16+
name: "locale in filename with dash",
17+
path: "src/Storefront/Resources/snippet/de_DE/storefront.de-DE.json",
18+
expected: "de-DE",
19+
},
20+
{
21+
name: "locale in filename with underscore",
22+
path: "src/Storefront/Resources/snippet/en_GB/storefront.en_GB.json",
23+
expected: "en-GB",
24+
},
25+
{
26+
name: "locale in directory with dash",
27+
path: "src/Core/Resources/snippet/de-DE/messages.json",
28+
expected: "de-DE",
29+
},
30+
{
31+
name: "locale in directory with underscore",
32+
path: "src/Core/Resources/snippet/de_DE/messages.json",
33+
expected: "de-DE",
34+
},
35+
{
36+
name: "locale in directory after snippet folder",
37+
path: "vendor/shopware/core/Resources/snippet/en_GB/storefront.json",
38+
expected: "en-GB",
39+
},
40+
{
41+
name: "short locale code in directory",
42+
path: "src/Resources/snippet/de/messages.json",
43+
expected: "de",
44+
},
45+
{
46+
name: "short locale code in filename",
47+
path: "src/Resources/snippet/translations.de.json",
48+
expected: "de",
49+
},
50+
{
51+
name: "no locale found",
52+
path: "src/Resources/translations/messages.json",
53+
expected: "unknown",
54+
},
55+
{
56+
name: "multiple locale patterns - prefer filename",
57+
path: "src/Resources/snippet/de_DE/storefront.en-GB.json",
58+
expected: "en-GB",
59+
},
60+
{
61+
name: "windows path with locale in directory",
62+
path: "src\\Storefront\\Resources\\snippet\\de_DE\\storefront.json",
63+
expected: "de-DE",
64+
},
65+
{
66+
name: "windows path with locale in filename",
67+
path: "src\\Storefront\\Resources\\snippet\\translations\\storefront.de-DE.json",
68+
expected: "de-DE",
69+
},
70+
{
71+
name: "locale with different case",
72+
path: "src/Resources/snippet/DE_DE/messages.json",
73+
expected: "DE-DE",
74+
},
75+
{
76+
name: "complex filename with multiple dots",
77+
path: "src/snippet/storefront.frontend.de-DE.min.json",
78+
expected: "de-DE",
79+
},
80+
{
81+
name: "locale at root level",
82+
path: "de-DE/messages.json",
83+
expected: "de-DE",
84+
},
85+
{
86+
name: "deeply nested path",
87+
path: "vendor/shopware/platform/src/Storefront/Resources/snippet/de_DE/storefront.json",
88+
expected: "de-DE",
89+
},
90+
}
91+
92+
for _, tt := range tests {
93+
t.Run(tt.name, func(t *testing.T) {
94+
result := extractLocaleFromPath(tt.path)
95+
assert.Equal(t, tt.expected, result)
96+
})
97+
}
98+
}
99+
100+
func TestIsLocalePattern(t *testing.T) {
101+
tests := []struct {
102+
name string
103+
input string
104+
expected bool
105+
}{
106+
{
107+
name: "valid locale with dash",
108+
input: "de-DE",
109+
expected: true,
110+
},
111+
{
112+
name: "valid locale with underscore",
113+
input: "en_GB",
114+
expected: true,
115+
},
116+
{
117+
name: "valid short locale",
118+
input: "de",
119+
expected: true,
120+
},
121+
{
122+
name: "valid short locale uppercase",
123+
input: "FR",
124+
expected: true,
125+
},
126+
{
127+
name: "invalid - too long",
128+
input: "deutsch",
129+
expected: false,
130+
},
131+
{
132+
name: "invalid - too short",
133+
input: "d",
134+
expected: false,
135+
},
136+
{
137+
name: "invalid - wrong separator position",
138+
input: "d-eDE",
139+
expected: false,
140+
},
141+
{
142+
name: "invalid - no separator",
143+
input: "deDE",
144+
expected: false,
145+
},
146+
{
147+
name: "invalid - wrong length with separator",
148+
input: "de-D",
149+
expected: false,
150+
},
151+
{
152+
name: "empty string",
153+
input: "",
154+
expected: false,
155+
},
156+
{
157+
name: "numbers only",
158+
input: "12-34",
159+
expected: true, // technically matches pattern
160+
},
161+
{
162+
name: "mixed case locale",
163+
input: "De-dE",
164+
expected: true,
165+
},
166+
}
167+
168+
for _, tt := range tests {
169+
t.Run(tt.name, func(t *testing.T) {
170+
result := isLocalePattern(tt.input)
171+
assert.Equal(t, tt.expected, result)
172+
})
173+
}
174+
}
175+
176+
func TestNormalizeLocale(t *testing.T) {
177+
tests := []struct {
178+
name string
179+
input string
180+
expected string
181+
}{
182+
{
183+
name: "underscore to dash",
184+
input: "de_DE",
185+
expected: "de-DE",
186+
},
187+
{
188+
name: "already normalized",
189+
input: "en-GB",
190+
expected: "en-GB",
191+
},
192+
{
193+
name: "multiple underscores",
194+
input: "de_DE_formal",
195+
expected: "de-DE-formal",
196+
},
197+
{
198+
name: "no underscores",
199+
input: "de",
200+
expected: "de",
201+
},
202+
{
203+
name: "empty string",
204+
input: "",
205+
expected: "",
206+
},
207+
{
208+
name: "mixed separators",
209+
input: "de_DE-CH",
210+
expected: "de-DE-CH",
211+
},
212+
}
213+
214+
for _, tt := range tests {
215+
t.Run(tt.name, func(t *testing.T) {
216+
result := normalizeLocale(tt.input)
217+
assert.Equal(t, tt.expected, result)
218+
})
219+
}
220+
}

0 commit comments

Comments
 (0)