@herb-tools/language-server 0.8.9 → 0.9.0

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.
Files changed (82) hide show
  1. package/dist/action_view_helpers.js +19 -0
  2. package/dist/action_view_helpers.js.map +1 -0
  3. package/dist/autofix_service.js +1 -1
  4. package/dist/autofix_service.js.map +1 -1
  5. package/dist/code_action_service.js +3 -6
  6. package/dist/code_action_service.js.map +1 -1
  7. package/dist/comment_ast_utils.js +206 -0
  8. package/dist/comment_ast_utils.js.map +1 -0
  9. package/dist/comment_service.js +175 -0
  10. package/dist/comment_service.js.map +1 -0
  11. package/dist/diagnostics.js +15 -27
  12. package/dist/diagnostics.js.map +1 -1
  13. package/dist/document_highlight_service.js +196 -0
  14. package/dist/document_highlight_service.js.map +1 -0
  15. package/dist/document_save_service.js +16 -6
  16. package/dist/document_save_service.js.map +1 -1
  17. package/dist/folding_range_service.js +209 -0
  18. package/dist/folding_range_service.js.map +1 -0
  19. package/dist/formatting_service.js +4 -7
  20. package/dist/formatting_service.js.map +1 -1
  21. package/dist/herb-language-server.js +150276 -41207
  22. package/dist/herb-language-server.js.map +1 -1
  23. package/dist/hover_service.js +70 -0
  24. package/dist/hover_service.js.map +1 -0
  25. package/dist/index.cjs +1227 -66
  26. package/dist/index.cjs.map +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/line_context_collector.js +73 -0
  30. package/dist/line_context_collector.js.map +1 -0
  31. package/dist/linter_service.js +20 -4
  32. package/dist/linter_service.js.map +1 -1
  33. package/dist/parser_service.js +6 -5
  34. package/dist/parser_service.js.map +1 -1
  35. package/dist/range_utils.js +65 -0
  36. package/dist/range_utils.js.map +1 -0
  37. package/dist/rewrite_code_action_service.js +135 -0
  38. package/dist/rewrite_code_action_service.js.map +1 -0
  39. package/dist/server.js +36 -2
  40. package/dist/server.js.map +1 -1
  41. package/dist/service.js +10 -0
  42. package/dist/service.js.map +1 -1
  43. package/dist/types/action_view_helpers.d.ts +5 -0
  44. package/dist/types/comment_ast_utils.d.ts +20 -0
  45. package/dist/types/comment_service.d.ts +14 -0
  46. package/dist/types/diagnostics.d.ts +0 -1
  47. package/dist/types/document_highlight_service.d.ts +28 -0
  48. package/dist/types/document_save_service.d.ts +8 -0
  49. package/dist/types/folding_range_service.d.ts +35 -0
  50. package/dist/types/formatting_service.d.ts +1 -1
  51. package/dist/types/hover_service.d.ts +8 -0
  52. package/dist/types/index.d.ts +4 -0
  53. package/dist/types/line_context_collector.d.ts +19 -0
  54. package/dist/types/linter_service.d.ts +1 -0
  55. package/dist/types/parser_service.d.ts +2 -1
  56. package/dist/types/range_utils.d.ts +16 -0
  57. package/dist/types/rewrite_code_action_service.d.ts +11 -0
  58. package/dist/types/service.d.ts +10 -0
  59. package/dist/types/utils.d.ts +0 -6
  60. package/dist/utils.js +0 -16
  61. package/dist/utils.js.map +1 -1
  62. package/package.json +10 -5
  63. package/src/action_view_helpers.ts +23 -0
  64. package/src/autofix_service.ts +1 -1
  65. package/src/code_action_service.ts +3 -6
  66. package/src/comment_ast_utils.ts +282 -0
  67. package/src/comment_service.ts +228 -0
  68. package/src/diagnostics.ts +24 -38
  69. package/src/document_highlight_service.ts +267 -0
  70. package/src/document_save_service.ts +19 -7
  71. package/src/folding_range_service.ts +287 -0
  72. package/src/formatting_service.ts +4 -8
  73. package/src/hover_service.ts +90 -0
  74. package/src/index.ts +4 -0
  75. package/src/line_context_collector.ts +97 -0
  76. package/src/linter_service.ts +25 -7
  77. package/src/parser_service.ts +9 -10
  78. package/src/range_utils.ts +90 -0
  79. package/src/rewrite_code_action_service.ts +165 -0
  80. package/src/server.ts +51 -2
  81. package/src/service.ts +15 -0
  82. package/src/utils.ts +0 -22
@@ -0,0 +1,11 @@
1
+ import { CodeAction, Range } from "vscode-languageserver/node";
2
+ import { TextDocument } from "vscode-languageserver-textdocument";
3
+ import { ParserService } from "./parser_service";
4
+ export declare class RewriteCodeActionService {
5
+ private parserService;
6
+ constructor(parserService: ParserService);
7
+ getCodeActions(document: TextDocument, requestedRange: Range): CodeAction[];
8
+ private createActionViewToHTMLAction;
9
+ private createHTMLToActionViewAction;
10
+ private rangesOverlap;
11
+ }
@@ -11,6 +11,11 @@ import { ConfigService } from "./config_service";
11
11
  import { AutofixService } from "./autofix_service";
12
12
  import { CodeActionService } from "./code_action_service";
13
13
  import { DocumentSaveService } from "./document_save_service";
14
+ import { FoldingRangeService } from "./folding_range_service";
15
+ import { DocumentHighlightService } from "./document_highlight_service";
16
+ import { HoverService } from "./hover_service";
17
+ import { RewriteCodeActionService } from "./rewrite_code_action_service";
18
+ import { CommentService } from "./comment_service";
14
19
  export declare class Service {
15
20
  connection: Connection;
16
21
  settings: Settings;
@@ -25,6 +30,11 @@ export declare class Service {
25
30
  configService: ConfigService;
26
31
  codeActionService: CodeActionService;
27
32
  documentSaveService: DocumentSaveService;
33
+ foldingRangeService: FoldingRangeService;
34
+ documentHighlightService: DocumentHighlightService;
35
+ hoverService: HoverService;
36
+ rewriteCodeActionService: RewriteCodeActionService;
37
+ commentService: CommentService;
28
38
  constructor(connection: Connection, params: InitializeParams);
29
39
  init(): Promise<void>;
30
40
  refresh(): Promise<void>;
@@ -1,12 +1,6 @@
1
1
  import { DiagnosticSeverity } from "vscode-languageserver/node";
2
2
  import type { LintSeverity } from "@herb-tools/linter";
3
- import type { Range } from "vscode-languageserver/node";
4
- import type { TextDocument } from "vscode-languageserver-textdocument";
5
3
  export declare function camelize(value: string): string;
6
4
  export declare function dasherize(value: string): string;
7
5
  export declare function capitalize(value: string): string;
8
6
  export declare function lintToDignosticSeverity(severity: LintSeverity): DiagnosticSeverity;
9
- /**
10
- * Returns a Range that spans the entire document
11
- */
12
- export declare function getFullDocumentRange(document: TextDocument): Range;
package/dist/utils.js CHANGED
@@ -1,5 +1,4 @@
1
1
  import { DiagnosticSeverity } from "vscode-languageserver/node";
2
- import { Position } from "vscode-languageserver/node";
3
2
  export function camelize(value) {
4
3
  return value.replace(/(?:[_-])([a-z0-9])/g, (_, char) => char.toUpperCase());
5
4
  }
@@ -17,19 +16,4 @@ export function lintToDignosticSeverity(severity) {
17
16
  case "hint": return DiagnosticSeverity.Hint;
18
17
  }
19
18
  }
20
- /**
21
- * Returns a Range that spans the entire document
22
- */
23
- export function getFullDocumentRange(document) {
24
- const lastLine = document.lineCount - 1;
25
- const lastLineText = document.getText({
26
- start: Position.create(lastLine, 0),
27
- end: Position.create(lastLine + 1, 0)
28
- });
29
- const lastLineLength = lastLineText.length;
30
- return {
31
- start: Position.create(0, 0),
32
- end: Position.create(lastLine, lastLineLength)
33
- };
34
- }
35
19
  //# sourceMappingURL=utils.js.map
package/dist/utils.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAA;AAG/D,OAAO,EAAE,QAAQ,EAAE,MAAM,4BAA4B,CAAA;AAKrD,MAAM,UAAU,QAAQ,CAAC,KAAa;IACpC,OAAO,KAAK,CAAC,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAA;AAC9E,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,KAAa;IACrC,OAAO,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC,CAAA;AACzE,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;AACvD,CAAC;AAED,MAAM,UAAU,uBAAuB,CAAC,QAAsB;IAC5D,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,OAAO,CAAC,CAAC,OAAO,kBAAkB,CAAC,KAAK,CAAA;QAC7C,KAAK,SAAS,CAAC,CAAC,OAAO,kBAAkB,CAAC,OAAO,CAAA;QACjD,KAAK,MAAM,CAAC,CAAC,OAAO,kBAAkB,CAAC,WAAW,CAAA;QAClD,KAAK,MAAM,CAAC,CAAC,OAAO,kBAAkB,CAAC,IAAI,CAAA;IAC7C,CAAC;AACH,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,oBAAoB,CAAC,QAAsB;IACzD,MAAM,QAAQ,GAAG,QAAQ,CAAC,SAAS,GAAG,CAAC,CAAA;IACvC,MAAM,YAAY,GAAG,QAAQ,CAAC,OAAO,CAAC;QACpC,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,CAAC,CAAC;QACnC,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,GAAG,CAAC,EAAE,CAAC,CAAC;KACtC,CAAC,CAAA;IACF,MAAM,cAAc,GAAG,YAAY,CAAC,MAAM,CAAA;IAE1C,OAAO;QACL,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC,EAAE,CAAC,CAAC;QAC5B,GAAG,EAAE,QAAQ,CAAC,MAAM,CAAC,QAAQ,EAAE,cAAc,CAAC;KAC/C,CAAA;AACH,CAAC"}
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAA;AAG/D,MAAM,UAAU,QAAQ,CAAC,KAAa;IACpC,OAAO,KAAK,CAAC,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC,WAAW,EAAE,CAAC,CAAA;AAC9E,CAAC;AAED,MAAM,UAAU,SAAS,CAAC,KAAa;IACrC,OAAO,KAAK,CAAC,OAAO,CAAC,UAAU,EAAE,CAAC,CAAC,EAAE,IAAI,EAAE,EAAE,CAAC,IAAI,IAAI,CAAC,WAAW,EAAE,EAAE,CAAC,CAAA;AACzE,CAAC;AAED,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,OAAO,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAA;AACvD,CAAC;AAED,MAAM,UAAU,uBAAuB,CAAC,QAAsB;IAC5D,QAAQ,QAAQ,EAAE,CAAC;QACjB,KAAK,OAAO,CAAC,CAAC,OAAO,kBAAkB,CAAC,KAAK,CAAA;QAC7C,KAAK,SAAS,CAAC,CAAC,OAAO,kBAAkB,CAAC,OAAO,CAAA;QACjD,KAAK,MAAM,CAAC,CAAC,OAAO,kBAAkB,CAAC,WAAW,CAAA;QAClD,KAAK,MAAM,CAAC,CAAC,OAAO,kBAAkB,CAAC,IAAI,CAAA;IAC7C,CAAC;AACH,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@herb-tools/language-server",
3
3
  "description": "Herb HTML+ERB Language Tools and Language Server Protocol integration.",
4
- "version": "0.8.9",
4
+ "version": "0.9.0",
5
5
  "author": "Marco Roth",
6
6
  "license": "MIT",
7
7
  "engines": {
@@ -45,12 +45,17 @@
45
45
  "dist/"
46
46
  ],
47
47
  "dependencies": {
48
- "@herb-tools/config": "0.8.9",
49
- "@herb-tools/formatter": "0.8.9",
50
- "@herb-tools/linter": "0.8.9",
51
- "@herb-tools/node-wasm": "0.8.9",
48
+ "@herb-tools/config": "0.9.0",
49
+ "@herb-tools/formatter": "0.9.0",
50
+ "@herb-tools/linter": "0.9.0",
51
+ "@herb-tools/node-wasm": "0.9.0",
52
+ "@herb-tools/printer": "0.9.0",
53
+ "@herb-tools/rewriter": "0.9.0",
52
54
  "dedent": "^1.7.0",
53
55
  "vscode-languageserver": "^9.0.1",
54
56
  "vscode-languageserver-textdocument": "^1.0.12"
57
+ },
58
+ "devDependencies": {
59
+ "@types/node": "^25.3.3"
55
60
  }
56
61
  }
@@ -0,0 +1,23 @@
1
+ export interface ActionViewHelperInfo {
2
+ signature: string
3
+ documentationURL: string
4
+ }
5
+
6
+ export const ACTION_VIEW_HELPERS: Record<string, ActionViewHelperInfo> = {
7
+ "ActionView::Helpers::TagHelper#tag": {
8
+ signature: "tag.<tag name>(optional content, options)",
9
+ documentationURL: "https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html#method-i-tag",
10
+ },
11
+ "ActionView::Helpers::TagHelper#content_tag": {
12
+ signature: "content_tag(name, content_or_options_with_block = nil, options = nil, escape = true, &block)",
13
+ documentationURL: "https://api.rubyonrails.org/classes/ActionView/Helpers/TagHelper.html#method-i-content_tag",
14
+ },
15
+ "ActionView::Helpers::UrlHelper#link_to": {
16
+ signature: "link_to(name = nil, options = nil, html_options = nil, &block)",
17
+ documentationURL: "https://api.rubyonrails.org/classes/ActionView/Helpers/UrlHelper.html#method-i-link_to",
18
+ },
19
+ "Turbo::FramesHelper#turbo_frame_tag": {
20
+ signature: "turbo_frame_tag(*ids, src: nil, target: nil, **attributes, &block)",
21
+ documentationURL: "https://www.rubydoc.info/github/hotwired/turbo-rails/Turbo/FramesHelper:turbo_frame_tag",
22
+ },
23
+ }
@@ -5,7 +5,7 @@ import { Herb } from "@herb-tools/node-wasm"
5
5
  import { Linter } from "@herb-tools/linter"
6
6
  import { Config } from "@herb-tools/config"
7
7
 
8
- import { getFullDocumentRange } from "./utils"
8
+ import { getFullDocumentRange } from "./range_utils"
9
9
 
10
10
  export class AutofixService {
11
11
  private connection: Connection
@@ -1,4 +1,4 @@
1
- import { CodeAction, CodeActionKind, CodeActionParams, Diagnostic, Range, Position, TextEdit, WorkspaceEdit, CreateFile, TextDocumentEdit, OptionalVersionedTextDocumentIdentifier } from "vscode-languageserver/node"
1
+ import { CodeAction, CodeActionKind, CodeActionParams, Diagnostic, Range, TextEdit, WorkspaceEdit, CreateFile, TextDocumentEdit, OptionalVersionedTextDocumentIdentifier } from "vscode-languageserver/node"
2
2
  import { TextDocument } from "vscode-languageserver-textdocument"
3
3
 
4
4
  import { Config } from "@herb-tools/config"
@@ -6,7 +6,7 @@ import { Project } from "./project"
6
6
  import { Herb } from "@herb-tools/node-wasm"
7
7
  import { Linter } from "@herb-tools/linter"
8
8
 
9
- import { getFullDocumentRange } from "./utils"
9
+ import { getFullDocumentRange, lspRangeFromLocation } from "./range_utils"
10
10
 
11
11
  import type { LintOffense } from "@herb-tools/linter"
12
12
 
@@ -362,10 +362,7 @@ export class CodeActionService {
362
362
  }
363
363
 
364
364
  private offenseToRange(offense: LintOffense): Range {
365
- return {
366
- start: Position.create(offense.location.start.line - 1, offense.location.start.column),
367
- end: Position.create(offense.location.end.line - 1, offense.location.end.column)
368
- }
365
+ return lspRangeFromLocation(offense.location)
369
366
  }
370
367
 
371
368
  private rangesEqual(r1: Range, r2: Range): boolean {
@@ -0,0 +1,282 @@
1
+ import { ParserService } from "./parser_service"
2
+ import { LineContextCollector } from "./line_context_collector"
3
+
4
+ import { HTMLCommentNode, Location, createSyntheticToken } from "@herb-tools/core"
5
+ import { IdentityPrinter } from "@herb-tools/printer"
6
+
7
+ import { isERBCommentNode, isLiteralNode, createLiteral } from "@herb-tools/core"
8
+ import { asMutable } from "@herb-tools/rewriter"
9
+
10
+ import type { Node, ERBContentNode } from "@herb-tools/core"
11
+
12
+ /**
13
+ * Commenting strategy for a line:
14
+ * - "single-erb": sole ERB tag on the line → insert # at column offset
15
+ * - "all-erb": multiple ERB tags with no significant HTML → # into each
16
+ * - "per-segment": control-flow ERB wrapping HTML → # each ERB, <!-- --> each HTML segment
17
+ * - "whole-line": output ERB mixed with HTML → wrap entire line in <!-- --> with ERB #
18
+ * - "html-only": pure HTML content → wrap in <!-- -->
19
+ */
20
+ export type CommentStrategy = "single-erb" | "all-erb" | "per-segment" | "whole-line" | "html-only"
21
+
22
+ interface LineSegment {
23
+ text: string
24
+ isERB: boolean
25
+ node?: ERBContentNode
26
+ }
27
+
28
+ export function commentERBNode(node: ERBContentNode): void {
29
+ const mutable = asMutable(node)
30
+
31
+ if (mutable.tag_opening) {
32
+ const currentValue = mutable.tag_opening.value
33
+
34
+ mutable.tag_opening = createSyntheticToken(
35
+ currentValue.substring(0, 2) + "#" + currentValue.substring(2),
36
+ mutable.tag_opening.type
37
+ )
38
+ }
39
+ }
40
+
41
+ export function uncommentERBNode(node: ERBContentNode): void {
42
+ const mutable = asMutable(node)
43
+
44
+ if (mutable.tag_opening && mutable.tag_opening.value === "<%#") {
45
+ const contentValue = mutable.content?.value || ""
46
+
47
+ if (
48
+ contentValue.startsWith(" graphql ") ||
49
+ contentValue.startsWith(" %= ") ||
50
+ contentValue.startsWith(" == ") ||
51
+ contentValue.startsWith(" % ") ||
52
+ contentValue.startsWith(" = ") ||
53
+ contentValue.startsWith(" - ")
54
+ ) {
55
+ mutable.tag_opening = createSyntheticToken("<%", mutable.tag_opening.type)
56
+ mutable.content = createSyntheticToken(contentValue.substring(1), mutable.content!.type)
57
+ } else {
58
+ mutable.tag_opening = createSyntheticToken("<%", mutable.tag_opening.type)
59
+ }
60
+ }
61
+ }
62
+
63
+ export function determineStrategy(erbNodes: ERBContentNode[], lineText: string): CommentStrategy {
64
+ if (erbNodes.length === 0) {
65
+ return "html-only"
66
+ }
67
+
68
+ if (erbNodes.length === 1) {
69
+ const node = erbNodes[0]
70
+ if (!node.tag_opening || !node.tag_closing) return "html-only"
71
+
72
+ const nodeStart = node.tag_opening.location.start.column
73
+ const nodeEnd = node.tag_closing.location.end.column
74
+ const isSoleContent = lineText.substring(0, nodeStart).trim() === "" && lineText.substring(nodeEnd).trim() === ""
75
+
76
+ if (isSoleContent) {
77
+ return "single-erb"
78
+ }
79
+
80
+ return "whole-line"
81
+ }
82
+
83
+ const segments = getLineSegments(lineText, erbNodes)
84
+ const hasHTML = segments.some(segment => !segment.isERB && segment.text.trim() !== "")
85
+
86
+ if (!hasHTML) {
87
+ return "all-erb"
88
+ }
89
+
90
+ const allControlTags = erbNodes.every(node => node.tag_opening?.value === "<%")
91
+
92
+ if (allControlTags) {
93
+ return "per-segment"
94
+ }
95
+
96
+ return "whole-line"
97
+ }
98
+
99
+ function getLineSegments(lineText: string, erbNodes: ERBContentNode[]): LineSegment[] {
100
+ const segments: LineSegment[] = []
101
+ const sorted = [...erbNodes].sort((a, b) => a.tag_opening!.location.start.column - b.tag_opening!.location.start.column)
102
+
103
+ let position = 0
104
+
105
+ for (const node of sorted) {
106
+ const nodeStart = node.tag_opening!.location.start.column
107
+ const nodeEnd = node.tag_closing!.location.end.column
108
+
109
+ if (nodeStart > position) {
110
+ segments.push({ text: lineText.substring(position, nodeStart), isERB: false })
111
+ }
112
+
113
+ segments.push({ text: lineText.substring(nodeStart, nodeEnd), isERB: true, node })
114
+ position = nodeEnd
115
+ }
116
+
117
+ if (position < lineText.length) {
118
+ segments.push({ text: lineText.substring(position), isERB: false })
119
+ }
120
+
121
+ return segments
122
+ }
123
+
124
+ /**
125
+ * Comment a line using AST mutation for strategies where the parser produces flat children,
126
+ * and text-segment manipulation for per-segment (where the parser nests nodes).
127
+ */
128
+ export function commentLineContent(content: string, erbNodes: ERBContentNode[], strategy: CommentStrategy, parserService: ParserService): string {
129
+ if (strategy === "per-segment") {
130
+ return commentPerSegment(content, erbNodes)
131
+ }
132
+
133
+ const parseResult = parserService.parseContent(content, { track_whitespace: true })
134
+ const lineCollector = new LineContextCollector()
135
+ parseResult.visit(lineCollector)
136
+
137
+ const lineERBNodes = lineCollector.erbNodesPerLine.get(0) || []
138
+ const document = parseResult.value
139
+ const children = asMutable(document).children
140
+
141
+ switch (strategy) {
142
+ case "all-erb":
143
+ for (const node of lineERBNodes) {
144
+ commentERBNode(node)
145
+ }
146
+ break
147
+
148
+ case "whole-line": {
149
+ for (const node of lineERBNodes) {
150
+ commentERBNode(node)
151
+ }
152
+
153
+ const commentNode = new HTMLCommentNode({
154
+ type: "AST_HTML_COMMENT_NODE",
155
+ location: Location.zero,
156
+ errors: [],
157
+ comment_start: createSyntheticToken("<!--", "TOKEN_HTML_COMMENT_START"),
158
+ children: [createLiteral(" "), ...(children.slice() as Node[]), createLiteral(" ")],
159
+ comment_end: createSyntheticToken("-->", "TOKEN_HTML_COMMENT_END"),
160
+ })
161
+
162
+ children.length = 0
163
+ children.push(commentNode)
164
+ break
165
+ }
166
+
167
+ case "html-only": {
168
+ const commentNode = new HTMLCommentNode({
169
+ type: "AST_HTML_COMMENT_NODE",
170
+ location: Location.zero,
171
+ errors: [],
172
+ comment_start: createSyntheticToken("<!--", "TOKEN_HTML_COMMENT_START"),
173
+ children: [createLiteral(" "), ...(children.slice() as Node[]), createLiteral(" ")],
174
+ comment_end: createSyntheticToken("-->", "TOKEN_HTML_COMMENT_END"),
175
+ })
176
+
177
+ children.length = 0
178
+ children.push(commentNode)
179
+ break
180
+ }
181
+ }
182
+
183
+ return IdentityPrinter.print(document, { ignoreErrors: true })
184
+ }
185
+
186
+ /**
187
+ * Per-segment commenting uses text segments because the parser creates nested
188
+ * structures (e.g., ERBIfNode) that don't allow flat child iteration.
189
+ */
190
+ function commentPerSegment(content: string, erbNodes: ERBContentNode[]): string {
191
+ const segments = getLineSegments(content, erbNodes)
192
+
193
+ return segments.map(segment => {
194
+ if (segment.isERB) {
195
+ return segment.text.substring(0, 2) + "#" + segment.text.substring(2)
196
+ } else if (segment.text.trim() !== "") {
197
+ return `<!-- ${segment.text} -->`
198
+ }
199
+
200
+ return segment.text
201
+ }).join("")
202
+ }
203
+
204
+ export function uncommentLineContent(content: string, parserService: ParserService): string {
205
+ const parseResult = parserService.parseContent(content, { track_whitespace: true })
206
+ const lineCollector = new LineContextCollector()
207
+
208
+ parseResult.visit(lineCollector)
209
+
210
+ const lineERBNodes = lineCollector.erbNodesPerLine.get(0) || []
211
+ const document = parseResult.value
212
+ const children = asMutable(document).children
213
+
214
+ for (const node of lineERBNodes) {
215
+ if (isERBCommentNode(node)) {
216
+ uncommentERBNode(node)
217
+ }
218
+ }
219
+
220
+ let index = 0
221
+
222
+ while (index < children.length) {
223
+ const child = children[index]
224
+
225
+ if (child.type === "AST_HTML_COMMENT_NODE") {
226
+ const commentNode = child as HTMLCommentNode
227
+ const innerChildren = [...commentNode.children]
228
+
229
+ if (innerChildren.length > 0) {
230
+ const first = innerChildren[0]
231
+
232
+ if (isLiteralNode(first) && first.content.startsWith(" ")) {
233
+ const trimmed = first.content.substring(1)
234
+
235
+ if (trimmed === "") {
236
+ innerChildren.shift()
237
+ } else {
238
+ innerChildren[0] = createLiteral(trimmed)
239
+ }
240
+ }
241
+ }
242
+
243
+ if (innerChildren.length > 0) {
244
+ const last = innerChildren[innerChildren.length - 1]
245
+
246
+ if (isLiteralNode(last) && last.content.endsWith(" ")) {
247
+ const trimmed = last.content.substring(0, last.content.length - 1)
248
+
249
+ if (trimmed === "") {
250
+ innerChildren.pop()
251
+ } else {
252
+ innerChildren[innerChildren.length - 1] = createLiteral(trimmed)
253
+ }
254
+ }
255
+ }
256
+
257
+ const innerERBNodes: ERBContentNode[] = []
258
+ const innerCollector = new LineContextCollector()
259
+
260
+ for (const innerChild of innerChildren) {
261
+ innerCollector.visit(innerChild)
262
+ }
263
+
264
+ innerERBNodes.push(...(innerCollector.erbNodesPerLine.get(0) || []))
265
+
266
+ for (const erbNode of innerERBNodes) {
267
+ if (isERBCommentNode(erbNode)) {
268
+ uncommentERBNode(erbNode)
269
+ }
270
+ }
271
+
272
+ children.splice(index, 1, ...innerChildren)
273
+ index += innerChildren.length
274
+
275
+ continue
276
+ }
277
+
278
+ index++
279
+ }
280
+
281
+ return IdentityPrinter.print(document, { ignoreErrors: true })
282
+ }
@@ -0,0 +1,228 @@
1
+ import { TextEdit, Range, Position } from "vscode-languageserver/node"
2
+ import { TextDocument } from "vscode-languageserver-textdocument"
3
+
4
+ import { ParserService } from "./parser_service"
5
+ import { LineContextCollector } from "./line_context_collector"
6
+
7
+ import { lspLine } from "./range_utils"
8
+ import { determineStrategy, commentLineContent, uncommentLineContent } from "./comment_ast_utils"
9
+
10
+ import type { LineInfo } from "./line_context_collector"
11
+ import type { ERBContentNode, HTMLCommentNode } from "@herb-tools/core"
12
+
13
+ export class CommentService {
14
+ private parserService: ParserService
15
+
16
+ constructor(parserService: ParserService) {
17
+ this.parserService = parserService
18
+ }
19
+
20
+ toggleLineComment(document: TextDocument, range: Range): TextEdit[] {
21
+ const parseResult = this.parserService.parseDocument(document)
22
+ const collector = new LineContextCollector()
23
+
24
+ collector.visit(parseResult.document)
25
+
26
+ const startLine = range.start.line
27
+ const endLine = range.end.line
28
+ const lineInfos: LineInfo[] = []
29
+
30
+ for (let line = startLine; line <= endLine; line++) {
31
+ const lineText = document.getText(Range.create(line, 0, line + 1, 0)).replace(/\n$/, "")
32
+
33
+ if (lineText.trim() === "") {
34
+ continue
35
+ }
36
+
37
+ if (this.lineIsIfFalseWrapped(lineText) !== null) {
38
+ lineInfos.push({ line, context: "erb-comment", node: null })
39
+ continue
40
+ }
41
+
42
+ const htmlCommentNode = collector.htmlCommentNodesPerLine.get(line)
43
+ const info = collector.lineMap.get(line)
44
+
45
+ if (htmlCommentNode && this.htmlCommentSpansLine(htmlCommentNode, lineText)) {
46
+ lineInfos.push({ line, context: "html-comment", node: htmlCommentNode })
47
+ } else if (info) {
48
+ if (info.context === "html-comment") {
49
+ lineInfos.push({ line, context: "html-content", node: null })
50
+ } else {
51
+ lineInfos.push(info)
52
+ }
53
+ } else {
54
+ lineInfos.push({ line, context: "html-content", node: null })
55
+ }
56
+ }
57
+
58
+ if (lineInfos.length === 0) return []
59
+
60
+ const allCommented = lineInfos.every(
61
+ info => info.context === "erb-comment" || info.context === "html-comment"
62
+ )
63
+
64
+ const edits: TextEdit[] = []
65
+
66
+ if (allCommented) {
67
+ for (const info of lineInfos) {
68
+ const lineText = document.getText(Range.create(info.line, 0, info.line + 1, 0)).replace(/\n$/, "")
69
+ const edit = this.uncommentLine(info, lineText, collector)
70
+
71
+ if (edit) edits.push(edit)
72
+ }
73
+ } else {
74
+ for (const info of lineInfos) {
75
+ if (info.context === "erb-comment" || info.context === "html-comment") continue
76
+
77
+ const lineText = document.getText(Range.create(info.line, 0, info.line + 1, 0)).replace(/\n$/, "")
78
+ const erbNodes = collector.erbNodesPerLine.get(info.line) || []
79
+ const edit = this.commentLine(info, lineText, erbNodes, collector)
80
+
81
+ if (edit) edits.push(edit)
82
+ }
83
+ }
84
+
85
+ return edits
86
+ }
87
+
88
+ toggleBlockComment(document: TextDocument, range: Range): TextEdit[] {
89
+ const startLine = range.start.line
90
+ const endLine = range.end.line
91
+
92
+ const firstLineText = document.getText(Range.create(startLine, 0, startLine + 1, 0)).replace(/\n$/, "")
93
+ const lastLineText = document.getText(Range.create(endLine, 0, endLine + 1, 0)).replace(/\n$/, "")
94
+ const isWrapped = firstLineText.trim() === "<% if false %>" && lastLineText.trim() === "<% end %>"
95
+
96
+ if (isWrapped) {
97
+ return [
98
+ TextEdit.del(Range.create(endLine, 0, endLine + 1, 0)),
99
+ TextEdit.del(Range.create(startLine, 0, startLine + 1, 0)),
100
+ ]
101
+ } else {
102
+ const firstLineIndent = this.getIndentation(firstLineText)
103
+
104
+ return [
105
+ TextEdit.insert(Position.create(endLine + 1, 0), `${firstLineIndent}<% end %>\n`),
106
+ TextEdit.insert(Position.create(startLine, 0), `${firstLineIndent}<% if false %>\n`),
107
+ ]
108
+ }
109
+ }
110
+
111
+ private commentLine(info: LineInfo, lineText: string, erbNodes: ERBContentNode[], collector: LineContextCollector): TextEdit | null {
112
+ const lineRange = Range.create(info.line, 0, info.line, lineText.length)
113
+ const indent = this.getIndentation(lineText)
114
+ const content = lineText.trimStart()
115
+ const htmlCommentNode = collector.htmlCommentNodesPerLine.get(info.line)
116
+
117
+ if (htmlCommentNode) {
118
+ return TextEdit.replace(lineRange, `${indent}<% if false %>${content}<% end %>`)
119
+ }
120
+
121
+ const strategy = determineStrategy(erbNodes, lineText)
122
+
123
+ if (strategy === "single-erb") {
124
+ const node = erbNodes[0]
125
+ const insertColumn = node.tag_opening!.location.start.column + 2
126
+
127
+ return TextEdit.insert(Position.create(info.line, insertColumn), "#")
128
+ }
129
+
130
+ const result = commentLineContent(content, erbNodes, strategy, this.parserService)
131
+
132
+ return TextEdit.replace(lineRange, indent + result)
133
+ }
134
+
135
+ private lineIsIfFalseWrapped(lineText: string): string | null {
136
+ const trimmed = lineText.trimStart()
137
+ const indent = this.getIndentation(lineText)
138
+
139
+ if (trimmed.startsWith("<% if false %>") && trimmed.endsWith("<% end %>")) {
140
+ const inner = trimmed.slice("<% if false %>".length, -"<% end %>".length)
141
+
142
+ return indent + inner
143
+ }
144
+
145
+ return null
146
+ }
147
+
148
+ private uncommentLine(info: LineInfo, lineText: string, collector: LineContextCollector): TextEdit | null {
149
+ const lineRange = Range.create(info.line, 0, info.line, lineText.length)
150
+ const indent = this.getIndentation(lineText)
151
+ const ifFalseContent = this.lineIsIfFalseWrapped(lineText)
152
+
153
+ if (ifFalseContent !== null) {
154
+ return TextEdit.replace(lineRange, ifFalseContent)
155
+ }
156
+
157
+ if (info.context === "erb-comment") {
158
+ const node = info.node as ERBContentNode
159
+ if (!node?.tag_opening || !node?.tag_closing) return null
160
+
161
+ const contentValue = (node as any).content?.value as string | null
162
+ const trimmedContent = contentValue?.trim() || ""
163
+
164
+ if (trimmedContent.startsWith("<") && !trimmedContent.startsWith("<%")) {
165
+ return TextEdit.replace(lineRange, `${indent}${trimmedContent}`)
166
+ }
167
+
168
+ if (lspLine(node.tag_opening.location.start) !== info.line) return null
169
+
170
+ const erbNodes = collector.erbNodesPerLine.get(info.line) || []
171
+
172
+ if (erbNodes.length > 1) {
173
+ const content = lineText.trimStart()
174
+ const result = uncommentLineContent(content, this.parserService)
175
+
176
+ return TextEdit.replace(lineRange, indent + result)
177
+ }
178
+
179
+ const hashColumn = node.tag_opening.location.start.column + 2
180
+
181
+ if (
182
+ contentValue?.startsWith(" graphql ") ||
183
+ contentValue?.startsWith(" %= ") ||
184
+ contentValue?.startsWith(" == ") ||
185
+ contentValue?.startsWith(" % ") ||
186
+ contentValue?.startsWith(" = ") ||
187
+ contentValue?.startsWith(" - ")
188
+ ) {
189
+ return TextEdit.del(Range.create(info.line, hashColumn, info.line, hashColumn + 2))
190
+ }
191
+
192
+ return TextEdit.del(Range.create(info.line, hashColumn, info.line, hashColumn + 1))
193
+ }
194
+
195
+ if (info.context === "html-comment") {
196
+ const commentNode = info.node as HTMLCommentNode | null
197
+
198
+ if (commentNode?.comment_start && commentNode?.comment_end) {
199
+ const contentStart = commentNode.comment_start.location.end.column
200
+ const contentEnd = commentNode.comment_end.location.start.column
201
+ const innerContent = lineText.substring(contentStart, contentEnd).trim()
202
+
203
+ const result = uncommentLineContent(innerContent, this.parserService)
204
+
205
+ return TextEdit.replace(lineRange, `${indent}${result}`)
206
+ }
207
+ }
208
+
209
+ return null
210
+ }
211
+
212
+ private htmlCommentSpansLine(node: HTMLCommentNode, lineText: string): boolean {
213
+ if (!node.comment_start || !node.comment_end) return false
214
+
215
+ const commentStart = node.comment_start.location.start.column
216
+ const commentEnd = node.comment_end.location.end.column
217
+ const contentBefore = lineText.substring(0, commentStart).trim()
218
+ const contentAfter = lineText.substring(commentEnd).trim()
219
+
220
+ return contentBefore === "" && contentAfter === ""
221
+ }
222
+
223
+ private getIndentation(lineText: string): string {
224
+ const match = lineText.match(/^(\s*)/)
225
+
226
+ return match ? match[1] : ""
227
+ }
228
+ }