@herb-tools/language-server 0.8.10 → 0.9.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.
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 +0 -233
  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 -4
  20. package/dist/formatting_service.js.map +1 -1
  21. package/dist/herb-language-server.js +152936 -41156
  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 +1299 -333
  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 +27 -6
  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 +39 -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 +1 -35
  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 +4 -8
  60. package/dist/utils.js +10 -15
  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 +1 -305
  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 -4
  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 +35 -9
  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 +54 -2
  81. package/src/service.ts +15 -0
  82. package/src/utils.ts +12 -21
@@ -30,5 +30,6 @@ export declare class LinterService {
30
30
  * Show warning message to user about failed custom rules
31
31
  */
32
32
  private showCustomRuleWarnings;
33
+ private shouldLintFile;
33
34
  lintDocument(textDocument: TextDocument): Promise<LintServiceResult>;
34
35
  }
@@ -1,10 +1,11 @@
1
1
  import { Diagnostic } from "vscode-languageserver/node";
2
2
  import { TextDocument } from "vscode-languageserver-textdocument";
3
- import type { DocumentNode } from "@herb-tools/node-wasm";
3
+ import type { DocumentNode, ParseResult, ParseOptions } from "@herb-tools/node-wasm";
4
4
  export interface ParseServiceResult {
5
5
  document: DocumentNode;
6
6
  diagnostics: Diagnostic[];
7
7
  }
8
8
  export declare class ParserService {
9
9
  parseDocument(textDocument: TextDocument): ParseServiceResult;
10
+ parseContent(content: string, options?: ParseOptions): ParseResult;
10
11
  }
@@ -0,0 +1,16 @@
1
+ import { Position, Range } from "vscode-languageserver/node";
2
+ import type { TextDocument } from "vscode-languageserver-textdocument";
3
+ import type { SerializedPosition, SerializedLocation, Node, Token, ERBNode, HTMLOpenTagNode } from "@herb-tools/core";
4
+ export declare function lspPosition(herbPosition: SerializedPosition): Position;
5
+ export declare function lspLine(herbPosition: SerializedPosition): number;
6
+ export declare function lspRangeFromLocation(herbLocation: SerializedLocation): Range;
7
+ export declare function erbTagToRange(node: ERBNode): Range | null;
8
+ export declare function tokenToRange(token: Token | null): Range | null;
9
+ export declare function nodeToRange(node: Node): Range;
10
+ export declare function openTagRanges(tag: HTMLOpenTagNode): (Range | null)[];
11
+ export declare function isPositionInRange(position: Position, range: Range): boolean;
12
+ export declare function rangeSize(range: Range): number;
13
+ /**
14
+ * Returns a Range that spans the entire document
15
+ */
16
+ export declare function getFullDocumentRange(document: TextDocument): Range;
@@ -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,8 @@
1
- import { DiagnosticSeverity } from "vscode-languageserver/node";
1
+ import { DiagnosticSeverity, DiagnosticTag } 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";
3
+ import type { DiagnosticSeverity as HerbDiagnosticSeverity, DiagnosticTag as HerbDiagnosticTag } from "@herb-tools/core";
5
4
  export declare function camelize(value: string): string;
6
5
  export declare function dasherize(value: string): string;
7
6
  export declare function capitalize(value: string): string;
8
- 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;
7
+ export declare function lintToDignosticSeverity(severity: LintSeverity | HerbDiagnosticSeverity): DiagnosticSeverity;
8
+ export declare function lintToDignosticTags(tags?: HerbDiagnosticTag[]): DiagnosticTag[];
package/dist/utils.js CHANGED
@@ -1,5 +1,4 @@
1
- import { DiagnosticSeverity } from "vscode-languageserver/node";
2
- import { Position } from "vscode-languageserver/node";
1
+ import { DiagnosticSeverity, DiagnosticTag } 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,15 @@ 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)
19
+ export function lintToDignosticTags(tags) {
20
+ if (!tags)
21
+ return [];
22
+ return tags.flatMap(tag => {
23
+ switch (tag) {
24
+ case "unnecessary": return [DiagnosticTag.Unnecessary];
25
+ case "deprecated": return [DiagnosticTag.Deprecated];
26
+ default: return [];
27
+ }
28
28
  });
29
- const lastLineLength = lastLineText.length;
30
- return {
31
- start: Position.create(0, 0),
32
- end: Position.create(lastLine, lastLineLength)
33
- };
34
29
  }
35
30
  //# 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,aAAa,EAAE,MAAM,4BAA4B,CAAA;AAI9E,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,QAA+C;IACrF,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,MAAM,UAAU,mBAAmB,CAAC,IAA0B;IAC5D,IAAI,CAAC,IAAI;QAAE,OAAO,EAAE,CAAA;IAEpB,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,EAAE;QACxB,QAAQ,GAAG,EAAE,CAAC;YACZ,KAAK,aAAa,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC,WAAW,CAAC,CAAA;YACtD,KAAK,YAAY,CAAC,CAAC,OAAO,CAAC,aAAa,CAAC,UAAU,CAAC,CAAA;YACpD,OAAO,CAAC,CAAC,OAAO,EAAE,CAAA;QACpB,CAAC;IACH,CAAC,CAAC,CAAA;AACJ,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.10",
4
+ "version": "0.9.1",
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.10",
49
- "@herb-tools/formatter": "0.8.10",
50
- "@herb-tools/linter": "0.8.10",
51
- "@herb-tools/node-wasm": "0.8.10",
48
+ "@herb-tools/config": "0.9.1",
49
+ "@herb-tools/formatter": "0.9.1",
50
+ "@herb-tools/linter": "0.9.1",
51
+ "@herb-tools/node-wasm": "0.9.1",
52
+ "@herb-tools/printer": "0.9.1",
53
+ "@herb-tools/rewriter": "0.9.1",
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
+ }