@herb-tools/core 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { Node, DocumentNode, LiteralNode, HTMLOpenTagNode, HTMLCloseTagNode, HTMLSelfCloseTagNode, HTMLElementNode, HTMLAttributeValueNode, HTMLAttributeNameNode, HTMLAttributeNode, HTMLTextNode, HTMLCommentNode, HTMLDoctypeNode, WhitespaceNode, ERBContentNode, ERBEndNode, ERBElseNode, ERBIfNode, ERBBlockNode, ERBWhenNode, ERBCaseNode, ERBCaseMatchNode, ERBWhileNode, ERBUntilNode, ERBForNode, ERBRescueNode, ERBEnsureNode, ERBBeginNode, ERBUnlessNode, ERBYieldNode, ERBInNode } from "./nodes.js";
1
+ import { Node, DocumentNode, LiteralNode, HTMLOpenTagNode, HTMLCloseTagNode, HTMLElementNode, HTMLAttributeValueNode, HTMLAttributeNameNode, HTMLAttributeNode, HTMLTextNode, HTMLCommentNode, HTMLDoctypeNode, XMLDeclarationNode, CDATANode, WhitespaceNode, ERBContentNode, ERBEndNode, ERBElseNode, ERBIfNode, ERBBlockNode, ERBWhenNode, ERBCaseNode, ERBCaseMatchNode, ERBWhileNode, ERBUntilNode, ERBForNode, ERBRescueNode, ERBEnsureNode, ERBBeginNode, ERBUnlessNode, ERBYieldNode, ERBInNode } from "./nodes.js";
2
2
  export declare class Visitor {
3
3
  visit(node: Node | null | undefined): void;
4
4
  visitAll(nodes: (Node | null | undefined)[]): void;
@@ -7,7 +7,6 @@ export declare class Visitor {
7
7
  visitLiteralNode(node: LiteralNode): void;
8
8
  visitHTMLOpenTagNode(node: HTMLOpenTagNode): void;
9
9
  visitHTMLCloseTagNode(node: HTMLCloseTagNode): void;
10
- visitHTMLSelfCloseTagNode(node: HTMLSelfCloseTagNode): void;
11
10
  visitHTMLElementNode(node: HTMLElementNode): void;
12
11
  visitHTMLAttributeValueNode(node: HTMLAttributeValueNode): void;
13
12
  visitHTMLAttributeNameNode(node: HTMLAttributeNameNode): void;
@@ -15,6 +14,8 @@ export declare class Visitor {
15
14
  visitHTMLTextNode(node: HTMLTextNode): void;
16
15
  visitHTMLCommentNode(node: HTMLCommentNode): void;
17
16
  visitHTMLDoctypeNode(node: HTMLDoctypeNode): void;
17
+ visitXMLDeclarationNode(node: XMLDeclarationNode): void;
18
+ visitCDATANode(node: CDATANode): void;
18
19
  visitWhitespaceNode(node: WhitespaceNode): void;
19
20
  visitERBContentNode(node: ERBContentNode): void;
20
21
  visitERBEndNode(node: ERBEndNode): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@herb-tools/core",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Core module exporting shared interfaces, AST node definitions, and common utilities for Herb",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,291 @@
1
+ import {
2
+ Node,
3
+ LiteralNode,
4
+ ERBContentNode,
5
+ ERBIfNode,
6
+ ERBUnlessNode,
7
+ ERBBlockNode,
8
+ ERBCaseNode,
9
+ ERBCaseMatchNode,
10
+ ERBWhileNode,
11
+ ERBForNode,
12
+ ERBBeginNode,
13
+ HTMLElementNode,
14
+ HTMLOpenTagNode,
15
+ HTMLCloseTagNode,
16
+ HTMLAttributeNameNode,
17
+ HTMLCommentNode
18
+ } from "./nodes.js"
19
+
20
+ import {
21
+ isNode,
22
+ isAnyOf,
23
+ isLiteralNode,
24
+ isERBNode,
25
+ isERBContentNode,
26
+ areAllOfType,
27
+ filterLiteralNodes
28
+ } from "./node-type-guards.js"
29
+
30
+ import type { Location } from "./location.js"
31
+ import type { Position } from "./position.js"
32
+
33
+ /**
34
+ * Checks if a node is an ERB output node (generates content: <%= %> or <%== %>)
35
+ */
36
+ export function isERBOutputNode(node: Node): node is ERBContentNode {
37
+ return isNode(node, ERBContentNode) && ["<%=", "<%=="].includes((node as ERBContentNode).tag_opening?.value!)
38
+ }
39
+
40
+ /**
41
+ * Checks if a node is a non-output ERB node (control flow: <% %>)
42
+ */
43
+ export function isERBControlFlowNode(node: Node): node is ERBContentNode {
44
+ return isAnyOf(node, ERBIfNode, ERBUnlessNode, ERBBlockNode, ERBCaseNode, ERBCaseMatchNode, ERBWhileNode, ERBForNode, ERBBeginNode)
45
+ }
46
+
47
+ /**
48
+ * Checks if an array of nodes contains any ERB content nodes
49
+ */
50
+ export function hasERBContent(nodes: Node[]): boolean {
51
+ return nodes.some(isERBContentNode)
52
+ }
53
+
54
+ /**
55
+ * Checks if an array of nodes contains any ERB output nodes (dynamic content)
56
+ */
57
+ export function hasERBOutput(nodes: Node[]): boolean {
58
+ return nodes.some(isERBOutputNode)
59
+ }
60
+
61
+
62
+ /**
63
+ * Extracts a static string from an array of literal nodes
64
+ * Returns null if any node is not a literal node
65
+ */
66
+ export function getStaticStringFromNodes(nodes: Node[]): string | null {
67
+ if (!areAllOfType(nodes, LiteralNode)) {
68
+ return null
69
+ }
70
+
71
+ return nodes.map(node => node.content).join("")
72
+ }
73
+
74
+ /**
75
+ * Extracts static content from nodes, including mixed literal/ERB content
76
+ * Returns the concatenated literal content, or null if no literal nodes exist
77
+ */
78
+ export function getStaticContentFromNodes(nodes: Node[]): string | null {
79
+ const literalNodes = filterLiteralNodes(nodes)
80
+
81
+ if (literalNodes.length === 0) {
82
+ return null
83
+ }
84
+
85
+ return literalNodes.map(node => node.content).join("")
86
+ }
87
+
88
+ /**
89
+ * Checks if nodes contain any literal content (for static validation)
90
+ */
91
+ export function hasStaticContent(nodes: Node[]): boolean {
92
+ return nodes.some(isLiteralNode)
93
+ }
94
+
95
+ /**
96
+ * Checks if nodes are effectively static (only literals and non-output ERB)
97
+ * Non-output ERB like <% if %> doesn't affect static validation
98
+ */
99
+ export function isEffectivelyStatic(nodes: Node[]): boolean {
100
+ return !hasERBOutput(nodes)
101
+ }
102
+
103
+ /**
104
+ * Gets static-validatable content from nodes (ignores control ERB, includes literals)
105
+ * Returns concatenated literal content for validation, or null if contains output ERB
106
+ */
107
+ export function getValidatableStaticContent(nodes: Node[]): string | null {
108
+ if (hasERBOutput(nodes)) {
109
+ return null
110
+ }
111
+
112
+ return filterLiteralNodes(nodes).map(node => node.content).join("")
113
+ }
114
+
115
+ /**
116
+ * Extracts a combined string from nodes, including ERB content
117
+ * For ERB nodes, includes the full tag syntax (e.g., "<%= foo %>")
118
+ * This is useful for debugging or displaying the full attribute name
119
+ */
120
+ export function getCombinedStringFromNodes(nodes: Node[]): string {
121
+ return nodes.map(node => {
122
+ if (isLiteralNode(node)) {
123
+ return node.content
124
+ } else if (isERBContentNode(node)) {
125
+ const opening = node.tag_opening?.value || ""
126
+ const content = node.content?.value || ""
127
+ const closing = node.tag_closing?.value || ""
128
+
129
+ return `${opening}${content}${closing}`
130
+ } else {
131
+ // For other node types, return a placeholder or empty string
132
+ return `[${node.type}]`
133
+ }
134
+ }).join("")
135
+ }
136
+
137
+ /**
138
+ * Checks if an HTML attribute name node has a static (literal-only) name
139
+ */
140
+ export function hasStaticAttributeName(attributeNameNode: HTMLAttributeNameNode): boolean {
141
+ if (!attributeNameNode.children) {
142
+ return false
143
+ }
144
+
145
+ return areAllOfType(attributeNameNode.children, LiteralNode)
146
+ }
147
+
148
+ /**
149
+ * Checks if an HTML attribute name node has dynamic content (contains ERB)
150
+ */
151
+ export function hasDynamicAttributeName(attributeNameNode: HTMLAttributeNameNode): boolean {
152
+ if (!attributeNameNode.children) {
153
+ return false
154
+ }
155
+
156
+ return hasERBContent(attributeNameNode.children)
157
+ }
158
+
159
+ /**
160
+ * Gets the static string value of an HTML attribute name node
161
+ * Returns null if the attribute name contains dynamic content (ERB)
162
+ */
163
+ export function getStaticAttributeName(attributeNameNode: HTMLAttributeNameNode): string | null {
164
+ if (!attributeNameNode.children) {
165
+ return null
166
+ }
167
+
168
+ return getStaticStringFromNodes(attributeNameNode.children)
169
+ }
170
+
171
+ /**
172
+ * Gets the combined string representation of an HTML attribute name node
173
+ * This includes both static and dynamic content, useful for debugging
174
+ */
175
+ export function getCombinedAttributeName(attributeNameNode: HTMLAttributeNameNode): string {
176
+ if (!attributeNameNode.children) {
177
+ return ""
178
+ }
179
+
180
+ return getCombinedStringFromNodes(attributeNameNode.children)
181
+ }
182
+
183
+ /**
184
+ * Gets the tag name of an HTML element node
185
+ */
186
+ export function getTagName(node: HTMLElementNode | HTMLOpenTagNode | HTMLCloseTagNode): string {
187
+ return node.tag_name?.value ?? ""
188
+ }
189
+
190
+ /**
191
+ * Check if a node is a comment (HTML comment or ERB comment)
192
+ */
193
+ export function isCommentNode(node: Node): boolean {
194
+ return isNode(node, HTMLCommentNode) || (isERBNode(node) && !isERBControlFlowNode(node))
195
+ }
196
+
197
+ /**
198
+ * Compares two positions to determine if the first comes before the second
199
+ * Returns true if pos1 comes before pos2 in source order
200
+ * @param inclusive - If true, returns true when positions are equal
201
+ */
202
+ function isPositionBefore(position1: Position, position2: Position, inclusive = false): boolean {
203
+ if (position1.line < position2.line) return true
204
+ if (position1.line > position2.line) return false
205
+
206
+ return inclusive ? position1.column <= position2.column : position1.column < position2.column
207
+ }
208
+
209
+ /**
210
+ * Compares two positions to determine if they are equal
211
+ * Returns true if pos1 and pos2 are at the same location
212
+ */
213
+ export function isPositionEqual(position1: Position, position2: Position): boolean {
214
+ return position1.line === position2.line && position1.column === position2.column
215
+ }
216
+
217
+ /**
218
+ * Compares two positions to determine if the first comes after the second
219
+ * Returns true if pos1 comes after pos2 in source order
220
+ * @param inclusive - If true, returns true when positions are equal
221
+ */
222
+ export function isPositionAfter(position1: Position, position2: Position, inclusive = false): boolean {
223
+ if (position1.line > position2.line) return true
224
+ if (position1.line < position2.line) return false
225
+
226
+ return inclusive ? position1.column >= position2.column : position1.column > position2.column
227
+ }
228
+
229
+ /**
230
+ * Gets nodes that appear before the specified location in source order
231
+ * Uses line and column positions to determine ordering
232
+ */
233
+ export function getNodesBeforeLocation<T extends Node>(nodes: T[], location: Location): T[] {
234
+ return nodes.filter(node =>
235
+ node.location && isPositionBefore(node.location.end, location.start)
236
+ )
237
+ }
238
+
239
+ /**
240
+ * Gets nodes that appear after the specified location in source order
241
+ * Uses line and column positions to determine ordering
242
+ */
243
+ export function getNodesAfterLocation<T extends Node>(nodes: T[], location: Location): T[] {
244
+ return nodes.filter(node =>
245
+ node.location && isPositionAfter(node.location.start, location.end)
246
+ )
247
+ }
248
+
249
+ /**
250
+ * Splits nodes into before and after the specified location
251
+ * Returns an object with `before` and `after` arrays
252
+ */
253
+ export function splitNodesAroundLocation<T extends Node>(nodes: T[], location: Location): { before: T[], after: T[] } {
254
+ return {
255
+ before: getNodesBeforeLocation(nodes, location),
256
+ after: getNodesAfterLocation(nodes, location)
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Splits nodes at a specific position
262
+ * Returns nodes that end before the position and nodes that start after the position
263
+ * More precise than splitNodesAroundLocation as it uses a single position point
264
+ * Uses the same defaults as the individual functions: before=exclusive, after=inclusive
265
+ */
266
+ export function splitNodesAroundPosition<T extends Node>(nodes: T[], position: Position): { before: T[], after: T[] } {
267
+ return {
268
+ before: getNodesBeforePosition(nodes, position), // uses default: inclusive = false
269
+ after: getNodesAfterPosition(nodes, position) // uses default: inclusive = true
270
+ }
271
+ }
272
+
273
+ /**
274
+ * Gets nodes that end before the specified position
275
+ * @param inclusive - If true, includes nodes that end exactly at the position (default: false, matching half-open interval semantics)
276
+ */
277
+ export function getNodesBeforePosition<T extends Node>(nodes: T[], position: Position, inclusive = false): T[] {
278
+ return nodes.filter(node =>
279
+ node.location && isPositionBefore(node.location.end, position, inclusive)
280
+ )
281
+ }
282
+
283
+ /**
284
+ * Gets nodes that start after the specified position
285
+ * @param inclusive - If true, includes nodes that start exactly at the position (default: true, matching typical boundary behavior)
286
+ */
287
+ export function getNodesAfterPosition<T extends Node>(nodes: T[], position: Position, inclusive = true): T[] {
288
+ return nodes.filter(node =>
289
+ node.location && isPositionAfter(node.location.start, position, inclusive)
290
+ )
291
+ }
package/src/backend.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import type { SerializedParseResult } from "./parse-result.js"
2
2
  import type { SerializedLexResult } from "./lex-result.js"
3
+ import type { ParserOptions } from "./parser-options.js"
3
4
 
4
5
  interface LibHerbBackendFunctions {
5
6
  lex: (source: string) => SerializedLexResult
6
7
  lexFile: (path: string) => SerializedLexResult
7
8
 
8
- parse: (source: string) => SerializedParseResult
9
+ parse: (source: string, options?: ParserOptions) => SerializedParseResult
9
10
  parseFile: (path: string) => SerializedParseResult
10
11
 
11
12
  extractRuby: (source: string) => string
package/src/errors.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // NOTE: This file is generated by the templates/template.rb script and should not
2
- // be modified manually. See /Users/marcoroth/Development/herb-release-0.5.0/templates/javascript/packages/core/src/errors.ts.erb
2
+ // be modified manually. See /Users/marcoroth/Development/herb-release-0.6.1/templates/javascript/packages/core/src/errors.ts.erb
3
3
 
4
4
  import { Location, SerializedLocation } from "./location.js"
5
5
  import { Token, SerializedToken } from "./token.js"
@@ -3,8 +3,10 @@ import packageJSON from "../package.json" with { type: "json" }
3
3
  import { ensureString } from "./util.js"
4
4
  import { LexResult } from "./lex-result.js"
5
5
  import { ParseResult } from "./parse-result.js"
6
+ import { DEFAULT_PARSER_OPTIONS } from "./parser-options.js"
6
7
 
7
8
  import type { LibHerbBackend, BackendPromise } from "./backend.js"
9
+ import type { ParserOptions } from "./parser-options.js"
8
10
 
9
11
  /**
10
12
  * The main Herb parser interface, providing methods to lex and parse input.
@@ -64,13 +66,16 @@ export abstract class HerbBackend {
64
66
  /**
65
67
  * Parses the given source string into a `ParseResult`.
66
68
  * @param source - The source code to parse.
69
+ * @param options - Optional parsing options.
67
70
  * @returns A `ParseResult` instance.
68
71
  * @throws Error if the backend is not loaded.
69
72
  */
70
- parse(source: string): ParseResult {
73
+ parse(source: string, options?: ParserOptions): ParseResult {
71
74
  this.ensureBackend()
72
75
 
73
- return ParseResult.from(this.backend.parse(ensureString(source)))
76
+ const mergedOptions = { ...DEFAULT_PARSER_OPTIONS, ...options }
77
+
78
+ return ParseResult.from(this.backend.parse(ensureString(source), mergedOptions))
74
79
  }
75
80
 
76
81
  /**
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./ast-utils.js"
1
2
  export * from "./backend.js"
2
3
  export * from "./diagnostic.js"
3
4
  export * from "./errors.js"
@@ -5,7 +6,9 @@ export * from "./herb-backend.js"
5
6
  export * from "./lex-result.js"
6
7
  export * from "./location.js"
7
8
  export * from "./nodes.js"
9
+ export * from "./node-type-guards.js"
8
10
  export * from "./parse-result.js"
11
+ export * from "./parser-options.js"
9
12
  export * from "./position.js"
10
13
  export * from "./range.js"
11
14
  export * from "./result.js"