Skip to content

Commit d9ff44c

Browse files
committed
Language Server: Implement Completion Provider
1 parent 61b5a68 commit d9ff44c

File tree

5 files changed

+874
-0
lines changed

5 files changed

+874
-0
lines changed
Lines changed: 303 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,303 @@
1+
import {
2+
CompletionItem,
3+
CompletionItemKind,
4+
CompletionList,
5+
InsertTextFormat,
6+
MarkupKind,
7+
Position,
8+
} from "vscode-languageserver/node"
9+
import { TextDocument } from "vscode-languageserver-textdocument"
10+
11+
import { Visitor, isERBContentNode, isHTMLOpenTagNode, isHTMLTextNode } from "@herb-tools/core"
12+
import { ParserService } from "./parser_service"
13+
import { nodeToRange, isPositionInRange, rangeSize } from "./range_utils"
14+
import { HTML_TAGS } from "./html_tags"
15+
import { ACTION_VIEW_HELPERS } from "./action_view_helpers"
16+
17+
import type { Node, ERBContentNode, HTMLOpenTagNode, HTMLTextNode } from "@herb-tools/core"
18+
import type { Range } from "vscode-languageserver/node"
19+
20+
const HTML_OPEN_TAG_PATTERN = /<(\w*)$/
21+
22+
const TAG_DOT_PATTERN = /tag\.(\w*)$/
23+
const CONTENT_TAG_SYMBOL_PATTERN = /content_tag\s+:(\w*)$/
24+
const ERB_EXPRESSION_PATTERN = /^(\w*)$/
25+
26+
const COMMON_TAGS = new Set([
27+
"div", "span", "p", "a", "button", "form", "input", "label",
28+
"ul", "ol", "li", "h1", "h2", "h3", "section", "header",
29+
"footer", "nav", "main", "article", "aside", "table", "img",
30+
])
31+
32+
interface HelperCompletionInfo {
33+
name: string
34+
signature: string
35+
documentationURL: string
36+
}
37+
38+
function extractHelpers(): HelperCompletionInfo[] {
39+
return Object.entries(ACTION_VIEW_HELPERS).map(([key, info]) => {
40+
const name = key.split("#").pop()!
41+
return { name, ...info }
42+
})
43+
}
44+
45+
const HELPERS = extractHelpers()
46+
47+
class NodeAtPositionCollector extends Visitor {
48+
public matches: { node: Node; range: Range }[] = []
49+
private position: Position
50+
51+
constructor(position: Position) {
52+
super()
53+
this.position = position
54+
}
55+
56+
visitERBContentNode(node: ERBContentNode): void {
57+
const range = nodeToRange(node)
58+
59+
if (isPositionInRange(this.position, range)) {
60+
this.matches.push({ node, range })
61+
}
62+
63+
this.visitChildNodes(node)
64+
}
65+
66+
visitHTMLOpenTagNode(node: HTMLOpenTagNode): void {
67+
const range = nodeToRange(node)
68+
69+
if (isPositionInRange(this.position, range)) {
70+
this.matches.push({ node, range })
71+
}
72+
73+
this.visitChildNodes(node)
74+
}
75+
76+
visitHTMLTextNode(node: HTMLTextNode): void {
77+
const range = nodeToRange(node)
78+
79+
if (isPositionInRange(this.position, range)) {
80+
this.matches.push({ node, range })
81+
}
82+
}
83+
}
84+
85+
export class CompletionService {
86+
private parserService: ParserService
87+
88+
constructor(parserService: ParserService) {
89+
this.parserService = parserService
90+
}
91+
92+
getCompletions(document: TextDocument, position: Position): CompletionList | null {
93+
const parseResult = this.parserService.parseContent(document.getText(), {
94+
track_whitespace: true,
95+
})
96+
97+
const collector = new NodeAtPositionCollector(position)
98+
collector.visit(parseResult.value)
99+
100+
const node = this.findDeepestNode(collector.matches)
101+
102+
const textAfterCursor = document.getText({
103+
start: position,
104+
end: Position.create(position.line + 1, 0),
105+
})
106+
107+
if (node && isERBContentNode(node)) {
108+
return this.getERBCompletions(node, position, textAfterCursor)
109+
}
110+
111+
if (node && isHTMLOpenTagNode(node)) {
112+
return this.getHTMLOpenTagCompletions(node, position)
113+
}
114+
115+
if (node && isHTMLTextNode(node)) {
116+
return this.getHTMLTextCompletions(document, position)
117+
}
118+
119+
return null
120+
}
121+
122+
private findDeepestNode(matches: { node: Node; range: Range }[]): Node | null {
123+
let best: Node | null = null
124+
let bestSize = Infinity
125+
126+
for (const match of matches) {
127+
const size = rangeSize(match.range)
128+
129+
if (size < bestSize) {
130+
bestSize = size
131+
best = match.node
132+
}
133+
}
134+
135+
return best
136+
}
137+
138+
private getERBCompletions(node: ERBContentNode, position: Position, textAfterCursor: string): CompletionList | null {
139+
if (!node.content) return null
140+
141+
const contentText = node.content.value
142+
const contentStart = node.content.location.start
143+
const cursorOffset = position.character - contentStart.column
144+
145+
const textBeforeCursor = contentText.substring(0, cursorOffset).trimStart()
146+
147+
const tagDotMatch = textBeforeCursor.match(TAG_DOT_PATTERN)
148+
149+
if (tagDotMatch) {
150+
const hasClosingERBTag = /^\s*%>/.test(textAfterCursor)
151+
return this.getTagDotCompletions(tagDotMatch[1], hasClosingERBTag)
152+
}
153+
154+
const contentTagMatch = textBeforeCursor.match(CONTENT_TAG_SYMBOL_PATTERN)
155+
156+
if (contentTagMatch) {
157+
const hasSpaceAfterCursor = /^\s/.test(textAfterCursor)
158+
return this.getContentTagCompletions(contentTagMatch[1], hasSpaceAfterCursor)
159+
}
160+
161+
const erbMatch = textBeforeCursor.match(ERB_EXPRESSION_PATTERN)
162+
163+
if (erbMatch) {
164+
return this.getHelperCompletions(erbMatch[1])
165+
}
166+
167+
return null
168+
}
169+
170+
private getHTMLOpenTagCompletions(node: HTMLOpenTagNode, position: Position): CompletionList | null {
171+
if (!node.tag_opening) return null
172+
173+
const tagOpenEnd = node.tag_opening.location.end
174+
const tagNameStart = node.tag_name?.location.start
175+
const tagNameEnd = node.tag_name?.location.end
176+
177+
const isAfterOpenBracket = position.line === tagOpenEnd.line - 1 && position.character >= tagOpenEnd.column
178+
const isInTagName = tagNameStart && tagNameEnd &&
179+
position.line >= tagNameStart.line - 1 && position.line <= tagNameEnd.line - 1 &&
180+
position.character >= tagNameStart.column && position.character <= tagNameEnd.column
181+
182+
if (!isAfterOpenBracket && !isInTagName) return null
183+
184+
const prefix = node.tag_name ? node.tag_name.value.substring(0, position.character - node.tag_name.location.start.column) : ""
185+
186+
return this.getHTMLTagCompletions(prefix)
187+
}
188+
189+
private getHTMLTextCompletions(document: TextDocument, position: Position): CompletionList | null {
190+
const lineText = document.getText({
191+
start: Position.create(position.line, 0),
192+
end: position,
193+
})
194+
195+
const match = lineText.match(HTML_OPEN_TAG_PATTERN)
196+
197+
if (match) {
198+
return this.getHTMLTagCompletions(match[1])
199+
}
200+
201+
return null
202+
}
203+
204+
private getTagDotCompletions(prefix: string, hasClosingERBTag: boolean): CompletionList {
205+
const items: CompletionItem[] = HTML_TAGS
206+
.filter(tag => tag.name.startsWith(prefix))
207+
.map((tag, index) => {
208+
const isCommon = COMMON_TAGS.has(tag.name)
209+
210+
let insertText: string
211+
212+
if (hasClosingERBTag) {
213+
insertText = tag.name
214+
} else if (tag.isVoid) {
215+
insertText = `${tag.name} $0 %>`
216+
} else {
217+
insertText = `${tag.name} do %>$0<% end %>`
218+
}
219+
220+
return {
221+
label: tag.name,
222+
kind: CompletionItemKind.Property,
223+
detail: `tag.${tag.name}${tag.description}`,
224+
sortText: `!0${isCommon ? "0" : "1"}${String(index).padStart(3, "0")}`,
225+
insertTextFormat: hasClosingERBTag ? InsertTextFormat.PlainText : InsertTextFormat.Snippet,
226+
insertText,
227+
preselect: isCommon,
228+
}
229+
})
230+
231+
return CompletionList.create(items, false)
232+
}
233+
234+
private getContentTagCompletions(prefix: string, hasSpaceAfterCursor: boolean): CompletionList {
235+
const items: CompletionItem[] = HTML_TAGS
236+
.filter(tag => tag.name.startsWith(prefix))
237+
.map((tag, index) => {
238+
const isCommon = COMMON_TAGS.has(tag.name)
239+
240+
return {
241+
label: `:${tag.name}`,
242+
kind: CompletionItemKind.Property,
243+
detail: `content_tag :${tag.name}${tag.description}`,
244+
sortText: `!0${isCommon ? "0" : "1"}${String(index).padStart(3, "0")}`,
245+
insertTextFormat: InsertTextFormat.PlainText,
246+
filterText: tag.name,
247+
insertText: hasSpaceAfterCursor ? tag.name : `${tag.name} `,
248+
preselect: isCommon,
249+
}
250+
})
251+
252+
return CompletionList.create(items, false)
253+
}
254+
255+
private getHTMLTagCompletions(prefix: string): CompletionList {
256+
const items: CompletionItem[] = HTML_TAGS
257+
.filter(tag => tag.name.startsWith(prefix))
258+
.map((tag, index) => {
259+
const isCommon = COMMON_TAGS.has(tag.name)
260+
const insertText = tag.isVoid
261+
? `${tag.name} $0/>`
262+
: `${tag.name}>$0</${tag.name}>`
263+
264+
return {
265+
label: tag.name,
266+
kind: CompletionItemKind.Property,
267+
detail: `<${tag.name}> — ${tag.description}`,
268+
sortText: `!0${isCommon ? "0" : "1"}${String(index).padStart(3, "0")}`,
269+
insertTextFormat: InsertTextFormat.Snippet,
270+
insertText,
271+
preselect: isCommon,
272+
}
273+
})
274+
275+
return CompletionList.create(items, false)
276+
}
277+
278+
private getHelperCompletions(prefix: string): CompletionList | null {
279+
const items: CompletionItem[] = HELPERS
280+
.filter(helper => helper.name.startsWith(prefix))
281+
.map((helper, index) => {
282+
return {
283+
label: helper.name,
284+
kind: CompletionItemKind.Function,
285+
detail: helper.signature,
286+
documentation: {
287+
kind: MarkupKind.Markdown,
288+
value: `[Documentation](${helper.documentationURL})`,
289+
},
290+
sortText: `!00${String(index).padStart(3, "0")}`,
291+
insertTextFormat: InsertTextFormat.PlainText,
292+
insertText: helper.name,
293+
preselect: true,
294+
}
295+
})
296+
297+
if (items.length === 0) {
298+
return null
299+
}
300+
301+
return CompletionList.create(items, false)
302+
}
303+
}

0 commit comments

Comments
 (0)