|
| 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