@herb-tools/linter 0.6.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.
package/dist/index.cjs CHANGED
@@ -18,6 +18,11 @@ class SourceRule {
18
18
  static type = "source";
19
19
  }
20
20
 
21
+ var ControlFlowType;
22
+ (function (ControlFlowType) {
23
+ ControlFlowType[ControlFlowType["CONDITIONAL"] = 0] = "CONDITIONAL";
24
+ ControlFlowType[ControlFlowType["LOOP"] = 1] = "LOOP";
25
+ })(ControlFlowType || (ControlFlowType = {}));
21
26
  /**
22
27
  * Base visitor class that provides common functionality for rule visitors
23
28
  */
@@ -50,6 +55,70 @@ class BaseRuleVisitor extends core.Visitor {
50
55
  this.offenses.push(this.createOffense(message, location, severity));
51
56
  }
52
57
  }
58
+ /**
59
+ * Mixin that adds control flow tracking capabilities to rule visitors
60
+ * This allows rules to track state across different control flow structures
61
+ * like if/else branches, loops, etc.
62
+ *
63
+ * @template TControlFlowState - Type for state passed between onEnterControlFlow and onExitControlFlow
64
+ * @template TBranchState - Type for state passed between onEnterBranch and onExitBranch
65
+ */
66
+ class ControlFlowTrackingVisitor extends BaseRuleVisitor {
67
+ isInControlFlow = false;
68
+ currentControlFlowType = null;
69
+ /**
70
+ * Handle visiting a control flow node with proper scope management
71
+ */
72
+ handleControlFlowNode(node, controlFlowType, visitChildren) {
73
+ const wasInControlFlow = this.isInControlFlow;
74
+ const previousControlFlowType = this.currentControlFlowType;
75
+ this.isInControlFlow = true;
76
+ this.currentControlFlowType = controlFlowType;
77
+ const stateToRestore = this.onEnterControlFlow(controlFlowType, wasInControlFlow);
78
+ visitChildren();
79
+ this.onExitControlFlow(controlFlowType, wasInControlFlow, stateToRestore);
80
+ this.isInControlFlow = wasInControlFlow;
81
+ this.currentControlFlowType = previousControlFlowType;
82
+ }
83
+ /**
84
+ * Handle visiting a branch node (like else, when) with proper scope management
85
+ */
86
+ startNewBranch(visitChildren) {
87
+ const stateToRestore = this.onEnterBranch();
88
+ visitChildren();
89
+ this.onExitBranch(stateToRestore);
90
+ }
91
+ visitERBIfNode(node) {
92
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBIfNode(node));
93
+ }
94
+ visitERBUnlessNode(node) {
95
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBUnlessNode(node));
96
+ }
97
+ visitERBCaseNode(node) {
98
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseNode(node));
99
+ }
100
+ visitERBCaseMatchNode(node) {
101
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBCaseMatchNode(node));
102
+ }
103
+ visitERBWhileNode(node) {
104
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBWhileNode(node));
105
+ }
106
+ visitERBForNode(node) {
107
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBForNode(node));
108
+ }
109
+ visitERBUntilNode(node) {
110
+ this.handleControlFlowNode(node, ControlFlowType.LOOP, () => super.visitERBUntilNode(node));
111
+ }
112
+ visitERBBlockNode(node) {
113
+ this.handleControlFlowNode(node, ControlFlowType.CONDITIONAL, () => super.visitERBBlockNode(node));
114
+ }
115
+ visitERBElseNode(node) {
116
+ this.startNewBranch(() => super.visitERBElseNode(node));
117
+ }
118
+ visitERBWhenNode(node) {
119
+ this.startNewBranch(() => super.visitERBWhenNode(node));
120
+ }
121
+ }
53
122
  /**
54
123
  * Gets attributes from an HTMLOpenTagNode
55
124
  */
@@ -612,82 +681,732 @@ class ERBNoSilentTagInAttributeNameRule extends ParserRule {
612
681
  }
613
682
  }
614
683
 
615
- class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
684
+ class PrintContext {
685
+ output = "";
686
+ indentLevel = 0;
687
+ currentColumn = 0;
688
+ preserveStack = [];
689
+ /**
690
+ * Write text to the output
691
+ */
692
+ write(text) {
693
+ this.output += text;
694
+ this.currentColumn += text.length;
695
+ }
696
+ /**
697
+ * Write text and update column tracking for newlines
698
+ */
699
+ writeWithColumnTracking(text) {
700
+ this.output += text;
701
+ const lines = text.split('\n');
702
+ if (lines.length > 1) {
703
+ this.currentColumn = lines[lines.length - 1].length;
704
+ }
705
+ else {
706
+ this.currentColumn += text.length;
707
+ }
708
+ }
709
+ /**
710
+ * Increase indentation level
711
+ */
712
+ indent() {
713
+ this.indentLevel++;
714
+ }
715
+ /**
716
+ * Decrease indentation level
717
+ */
718
+ dedent() {
719
+ if (this.indentLevel > 0) {
720
+ this.indentLevel--;
721
+ }
722
+ }
723
+ /**
724
+ * Enter a tag that may preserve whitespace
725
+ */
726
+ enterTag(tagName) {
727
+ this.preserveStack.push(tagName.toLowerCase());
728
+ }
729
+ /**
730
+ * Exit the current tag
731
+ */
732
+ exitTag() {
733
+ this.preserveStack.pop();
734
+ }
735
+ /**
736
+ * Check if we're at the start of a line
737
+ */
738
+ isAtStartOfLine() {
739
+ return this.currentColumn === 0;
740
+ }
741
+ /**
742
+ * Get current indentation level
743
+ */
744
+ getCurrentIndentLevel() {
745
+ return this.indentLevel;
746
+ }
747
+ /**
748
+ * Get current column position
749
+ */
750
+ getCurrentColumn() {
751
+ return this.currentColumn;
752
+ }
753
+ /**
754
+ * Get the current tag stack (for debugging)
755
+ */
756
+ getTagStack() {
757
+ return [...this.preserveStack];
758
+ }
759
+ /**
760
+ * Get the complete output string
761
+ */
762
+ getOutput() {
763
+ return this.output;
764
+ }
765
+ /**
766
+ * Reset the context for reuse
767
+ */
768
+ reset() {
769
+ this.output = "";
770
+ this.indentLevel = 0;
771
+ this.currentColumn = 0;
772
+ this.preserveStack = [];
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Default print options used when none are provided
778
+ */
779
+ const DEFAULT_PRINT_OPTIONS = {
780
+ ignoreErrors: false
781
+ };
782
+ class Printer extends core.Visitor {
783
+ context = new PrintContext();
784
+ /**
785
+ * Static method to print a node without creating an instance
786
+ *
787
+ * @param input - The AST Node, Token, or ParseResult to print
788
+ * @param options - Print options to control behavior
789
+ * @returns The printed string representation of the input
790
+ * @throws {Error} When node has parse errors and ignoreErrors is false
791
+ */
792
+ static print(input, options = DEFAULT_PRINT_OPTIONS) {
793
+ const printer = new this();
794
+ return printer.print(input, options);
795
+ }
796
+ /**
797
+ * Print a node, token, or parse result to a string
798
+ *
799
+ * @param input - The AST Node, Token, or ParseResult to print
800
+ * @param options - Print options to control behavior
801
+ * @returns The printed string representation of the input
802
+ * @throws {Error} When node has parse errors and ignoreErrors is false
803
+ */
804
+ print(input, options = DEFAULT_PRINT_OPTIONS) {
805
+ if (core.isToken(input)) {
806
+ return input.value;
807
+ }
808
+ if (Array.isArray(input)) {
809
+ this.context.reset();
810
+ input.forEach(node => this.visit(node));
811
+ return this.context.getOutput();
812
+ }
813
+ const node = core.isParseResult(input) ? input.value : input;
814
+ if (options.ignoreErrors === false && node.recursiveErrors().length > 0) {
815
+ throw new Error(`Cannot print the node (${node.type}) since it or any of its children has parse errors. Either pass in a valid Node or call \`print()\` using \`print(node, { ignoreErrors: true })\``);
816
+ }
817
+ this.context.reset();
818
+ this.visit(node);
819
+ return this.context.getOutput();
820
+ }
821
+ write(content) {
822
+ this.context.write(content);
823
+ }
824
+ }
825
+
826
+ /**
827
+ * IdentityPrinter - Provides lossless reconstruction of the original source
828
+ *
829
+ * This printer aims to reconstruct the original input as faithfully as possible,
830
+ * preserving all whitespace, formatting, and structure. It's useful for:
831
+ * - Testing parser accuracy (input should equal output)
832
+ * - Baseline printing before applying transformations
833
+ * - Verifying AST round-trip fidelity
834
+ */
835
+ class IdentityPrinter extends Printer {
836
+ visitLiteralNode(node) {
837
+ this.write(node.content);
838
+ }
839
+ visitHTMLTextNode(node) {
840
+ this.write(node.content);
841
+ }
842
+ visitWhitespaceNode(node) {
843
+ if (node.value) {
844
+ this.write(node.value.value);
845
+ }
846
+ }
616
847
  visitHTMLOpenTagNode(node) {
617
- this.checkImgTag(node);
618
- super.visitHTMLOpenTagNode(node);
848
+ if (node.tag_opening) {
849
+ this.write(node.tag_opening.value);
850
+ }
851
+ if (node.tag_name) {
852
+ this.write(node.tag_name.value);
853
+ }
854
+ this.visitChildNodes(node);
855
+ if (node.tag_closing) {
856
+ this.write(node.tag_closing.value);
857
+ }
619
858
  }
620
- checkImgTag(node) {
621
- const tagName = getTagName(node);
622
- if (tagName !== "img") {
623
- return;
859
+ visitHTMLCloseTagNode(node) {
860
+ if (node.tag_opening) {
861
+ this.write(node.tag_opening.value);
624
862
  }
625
- const attributes = getAttributes(node);
626
- const srcAttribute = findAttributeByName(attributes, "src");
627
- if (!srcAttribute) {
628
- return;
863
+ if (node.tag_name) {
864
+ const before = core.getNodesBeforePosition(node.children, node.tag_name.location.start, true);
865
+ const after = core.getNodesAfterPosition(node.children, node.tag_name.location.end);
866
+ this.visitAll(before);
867
+ this.write(node.tag_name.value);
868
+ this.visitAll(after);
629
869
  }
630
- if (!srcAttribute.value) {
631
- return;
870
+ else {
871
+ this.visitAll(node.children);
632
872
  }
633
- const valueNode = srcAttribute.value;
634
- const hasERBContent = this.containsERBContent(valueNode);
635
- if (hasERBContent) {
636
- const suggestedExpression = this.buildSuggestedExpression(valueNode);
637
- this.addOffense(`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`, srcAttribute.location, "warning");
873
+ if (node.tag_closing) {
874
+ this.write(node.tag_closing.value);
638
875
  }
639
876
  }
640
- containsERBContent(valueNode) {
641
- if (!valueNode.children)
642
- return false;
643
- return valueNode.children.some(child => child.type === "AST_ERB_CONTENT_NODE");
877
+ visitHTMLElementNode(node) {
878
+ const tagName = node.tag_name?.value;
879
+ if (tagName) {
880
+ this.context.enterTag(tagName);
881
+ }
882
+ if (node.open_tag) {
883
+ this.visit(node.open_tag);
884
+ }
885
+ if (node.body) {
886
+ node.body.forEach(child => this.visit(child));
887
+ }
888
+ if (node.close_tag) {
889
+ this.visit(node.close_tag);
890
+ }
891
+ if (tagName) {
892
+ this.context.exitTag();
893
+ }
644
894
  }
645
- buildSuggestedExpression(valueNode) {
646
- if (!valueNode.children)
647
- return "expression";
648
- let hasText = false;
649
- let hasERB = false;
650
- for (const child of valueNode.children) {
651
- if (child.type === "AST_ERB_CONTENT_NODE") {
652
- hasERB = true;
895
+ visitHTMLAttributeNode(node) {
896
+ if (node.name) {
897
+ this.visit(node.name);
898
+ }
899
+ if (node.equals) {
900
+ this.write(node.equals.value);
901
+ }
902
+ if (node.equals && node.value) {
903
+ this.visit(node.value);
904
+ }
905
+ }
906
+ visitHTMLAttributeNameNode(node) {
907
+ this.visitChildNodes(node);
908
+ }
909
+ visitHTMLAttributeValueNode(node) {
910
+ if (node.quoted && node.open_quote) {
911
+ this.write(node.open_quote.value);
912
+ }
913
+ this.visitChildNodes(node);
914
+ if (node.quoted && node.close_quote) {
915
+ this.write(node.close_quote.value);
916
+ }
917
+ }
918
+ visitHTMLCommentNode(node) {
919
+ if (node.comment_start) {
920
+ this.write(node.comment_start.value);
921
+ }
922
+ this.visitChildNodes(node);
923
+ if (node.comment_end) {
924
+ this.write(node.comment_end.value);
925
+ }
926
+ }
927
+ visitHTMLDoctypeNode(node) {
928
+ if (node.tag_opening) {
929
+ this.write(node.tag_opening.value);
930
+ }
931
+ this.visitChildNodes(node);
932
+ if (node.tag_closing) {
933
+ this.write(node.tag_closing.value);
934
+ }
935
+ }
936
+ visitXMLDeclarationNode(node) {
937
+ if (node.tag_opening) {
938
+ this.write(node.tag_opening.value);
939
+ }
940
+ this.visitChildNodes(node);
941
+ if (node.tag_closing) {
942
+ this.write(node.tag_closing.value);
943
+ }
944
+ }
945
+ visitCDATANode(node) {
946
+ if (node.tag_opening) {
947
+ this.write(node.tag_opening.value);
948
+ }
949
+ this.visitChildNodes(node);
950
+ if (node.tag_closing) {
951
+ this.write(node.tag_closing.value);
952
+ }
953
+ }
954
+ visitERBContentNode(node) {
955
+ this.printERBNode(node);
956
+ }
957
+ visitERBIfNode(node) {
958
+ this.printERBNode(node);
959
+ if (node.statements) {
960
+ node.statements.forEach(statement => this.visit(statement));
961
+ }
962
+ if (node.subsequent) {
963
+ this.visit(node.subsequent);
964
+ }
965
+ if (node.end_node) {
966
+ this.visit(node.end_node);
967
+ }
968
+ }
969
+ visitERBElseNode(node) {
970
+ this.printERBNode(node);
971
+ if (node.statements) {
972
+ node.statements.forEach(statement => this.visit(statement));
973
+ }
974
+ }
975
+ visitERBEndNode(node) {
976
+ this.printERBNode(node);
977
+ }
978
+ visitERBBlockNode(node) {
979
+ this.printERBNode(node);
980
+ if (node.body) {
981
+ node.body.forEach(child => this.visit(child));
982
+ }
983
+ if (node.end_node) {
984
+ this.visit(node.end_node);
985
+ }
986
+ }
987
+ visitERBCaseNode(node) {
988
+ this.printERBNode(node);
989
+ if (node.children) {
990
+ node.children.forEach(child => this.visit(child));
991
+ }
992
+ if (node.conditions) {
993
+ node.conditions.forEach(condition => this.visit(condition));
994
+ }
995
+ if (node.else_clause) {
996
+ this.visit(node.else_clause);
997
+ }
998
+ if (node.end_node) {
999
+ this.visit(node.end_node);
1000
+ }
1001
+ }
1002
+ visitERBWhenNode(node) {
1003
+ this.printERBNode(node);
1004
+ if (node.statements) {
1005
+ node.statements.forEach(statement => this.visit(statement));
1006
+ }
1007
+ }
1008
+ visitERBWhileNode(node) {
1009
+ this.printERBNode(node);
1010
+ if (node.statements) {
1011
+ node.statements.forEach(statement => this.visit(statement));
1012
+ }
1013
+ if (node.end_node) {
1014
+ this.visit(node.end_node);
1015
+ }
1016
+ }
1017
+ visitERBUntilNode(node) {
1018
+ this.printERBNode(node);
1019
+ if (node.statements) {
1020
+ node.statements.forEach(statement => this.visit(statement));
1021
+ }
1022
+ if (node.end_node) {
1023
+ this.visit(node.end_node);
1024
+ }
1025
+ }
1026
+ visitERBForNode(node) {
1027
+ this.printERBNode(node);
1028
+ if (node.statements) {
1029
+ node.statements.forEach(statement => this.visit(statement));
1030
+ }
1031
+ if (node.end_node) {
1032
+ this.visit(node.end_node);
1033
+ }
1034
+ }
1035
+ visitERBBeginNode(node) {
1036
+ this.printERBNode(node);
1037
+ if (node.statements) {
1038
+ node.statements.forEach(statement => this.visit(statement));
1039
+ }
1040
+ if (node.rescue_clause) {
1041
+ this.visit(node.rescue_clause);
1042
+ }
1043
+ if (node.else_clause) {
1044
+ this.visit(node.else_clause);
1045
+ }
1046
+ if (node.ensure_clause) {
1047
+ this.visit(node.ensure_clause);
1048
+ }
1049
+ if (node.end_node) {
1050
+ this.visit(node.end_node);
1051
+ }
1052
+ }
1053
+ visitERBRescueNode(node) {
1054
+ this.printERBNode(node);
1055
+ if (node.statements) {
1056
+ node.statements.forEach(statement => this.visit(statement));
1057
+ }
1058
+ if (node.subsequent) {
1059
+ this.visit(node.subsequent);
1060
+ }
1061
+ }
1062
+ visitERBEnsureNode(node) {
1063
+ this.printERBNode(node);
1064
+ if (node.statements) {
1065
+ node.statements.forEach(statement => this.visit(statement));
1066
+ }
1067
+ }
1068
+ visitERBUnlessNode(node) {
1069
+ this.printERBNode(node);
1070
+ if (node.statements) {
1071
+ node.statements.forEach(statement => this.visit(statement));
1072
+ }
1073
+ if (node.else_clause) {
1074
+ this.visit(node.else_clause);
1075
+ }
1076
+ if (node.end_node) {
1077
+ this.visit(node.end_node);
1078
+ }
1079
+ }
1080
+ visitERBYieldNode(node) {
1081
+ this.printERBNode(node);
1082
+ }
1083
+ visitERBInNode(node) {
1084
+ this.printERBNode(node);
1085
+ if (node.statements) {
1086
+ node.statements.forEach(statement => this.visit(statement));
1087
+ }
1088
+ }
1089
+ visitERBCaseMatchNode(node) {
1090
+ this.printERBNode(node);
1091
+ if (node.children) {
1092
+ node.children.forEach(child => this.visit(child));
1093
+ }
1094
+ if (node.conditions) {
1095
+ node.conditions.forEach(condition => this.visit(condition));
1096
+ }
1097
+ if (node.else_clause) {
1098
+ this.visit(node.else_clause);
1099
+ }
1100
+ if (node.end_node) {
1101
+ this.visit(node.end_node);
1102
+ }
1103
+ }
1104
+ /**
1105
+ * Print ERB node tags and content
1106
+ */
1107
+ printERBNode(node) {
1108
+ if (node.tag_opening) {
1109
+ this.write(node.tag_opening.value);
1110
+ }
1111
+ if (node.content) {
1112
+ this.write(node.content.value);
1113
+ }
1114
+ if (node.tag_closing) {
1115
+ this.write(node.tag_closing.value);
1116
+ }
1117
+ }
1118
+ }
1119
+
1120
+ const DEFAULT_ERB_TO_RUBY_STRING_OPTIONS = {
1121
+ ...DEFAULT_PRINT_OPTIONS,
1122
+ forceQuotes: false
1123
+ };
1124
+ /**
1125
+ * ERBToRubyStringPrinter - Converts ERB snippets to Ruby strings with interpolation
1126
+ *
1127
+ * This printer transforms ERB templates into Ruby strings by:
1128
+ * - Converting literal text to string content
1129
+ * - Converting <%= %> tags to #{} interpolation
1130
+ * - Converting simple if/else blocks to ternary operators
1131
+ * - Ignoring <% %> tags (they don't produce output)
1132
+ *
1133
+ * Examples:
1134
+ * - `hello world <%= hello %>` => `"hello world #{hello}"`
1135
+ * - `hello world <% hello %>` => `"hello world "`
1136
+ * - `Welcome <%= user.name %>!` => `"Welcome #{user.name}!"`
1137
+ * - `<% if logged_in? %>Welcome<% else %>Login<% end %>` => `"logged_in? ? "Welcome" : "Login"`
1138
+ * - `<% if logged_in? %>Welcome<% else %>Login<% end %>!` => `"#{logged_in? ? "Welcome" : "Login"}!"`
1139
+ */
1140
+ class ERBToRubyStringPrinter extends IdentityPrinter {
1141
+ // TODO: cleanup `.type === "AST_*" checks`
1142
+ static print(node, options = DEFAULT_ERB_TO_RUBY_STRING_OPTIONS) {
1143
+ const erbNodes = core.filterNodes([node], core.ERBContentNode);
1144
+ if (erbNodes.length === 1 && core.isERBOutputNode(erbNodes[0]) && !options.forceQuotes) {
1145
+ return (erbNodes[0].content?.value || "").trim();
1146
+ }
1147
+ if ('children' in node && Array.isArray(node.children)) {
1148
+ const childErbNodes = core.filterNodes(node.children, core.ERBContentNode);
1149
+ const hasOnlyERBContent = node.children.length > 0 && node.children.length === childErbNodes.length;
1150
+ if (hasOnlyERBContent && childErbNodes.length === 1 && core.isERBOutputNode(childErbNodes[0]) && !options.forceQuotes) {
1151
+ return (childErbNodes[0].content?.value || "").trim();
653
1152
  }
654
- else if (child.type === "AST_LITERAL_NODE") {
655
- const literalNode = child;
656
- if (literalNode.content && literalNode.content.trim()) {
657
- hasText = true;
1153
+ if (node.children.length === 1 && node.children[0].type === "AST_ERB_IF_NODE" && !options.forceQuotes) {
1154
+ const ifNode = node.children[0];
1155
+ const printer = new ERBToRubyStringPrinter();
1156
+ if (printer.canConvertToTernary(ifNode)) {
1157
+ printer.convertToTernaryWithoutWrapper(ifNode);
1158
+ return printer.context.getOutput();
658
1159
  }
659
1160
  }
660
- }
661
- if (hasText && hasERB) {
662
- let result = '"';
663
- for (const child of valueNode.children) {
664
- if (child.type === "AST_ERB_CONTENT_NODE") {
665
- const erbNode = child;
666
- result += `#{${(erbNode.content?.value || "").trim()}}`;
667
- }
668
- else if (child.type === "AST_LITERAL_NODE") {
669
- const literalNode = child;
670
- result += literalNode.content || "";
1161
+ if (node.children.length === 1 && node.children[0].type === "AST_ERB_UNLESS_NODE" && !options.forceQuotes) {
1162
+ const unlessNode = node.children[0];
1163
+ const printer = new ERBToRubyStringPrinter();
1164
+ if (printer.canConvertUnlessToTernary(unlessNode)) {
1165
+ printer.convertUnlessToTernaryWithoutWrapper(unlessNode);
1166
+ return printer.context.getOutput();
671
1167
  }
672
1168
  }
673
- result += '"';
674
- return result;
675
1169
  }
676
- if (hasERB && !hasText) {
677
- const erbNodes = valueNode.children.filter(child => child.type === "AST_ERB_CONTENT_NODE");
678
- if (erbNodes.length === 1) {
679
- return (erbNodes[0].content?.value || "").trim();
1170
+ const printer = new ERBToRubyStringPrinter();
1171
+ printer.context.write('"');
1172
+ printer.visit(node);
1173
+ printer.context.write('"');
1174
+ return printer.context.getOutput();
1175
+ }
1176
+ visitHTMLTextNode(node) {
1177
+ if (node.content) {
1178
+ const escapedContent = node.content.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1179
+ this.context.write(escapedContent);
1180
+ }
1181
+ }
1182
+ visitERBContentNode(node) {
1183
+ if (core.isERBOutputNode(node)) {
1184
+ this.context.write("#{");
1185
+ if (node.content?.value) {
1186
+ this.context.write(node.content.value.trim());
680
1187
  }
681
- else if (erbNodes.length > 1) {
682
- let result = '"';
683
- for (const erbNode of erbNodes) {
684
- result += `#{${(erbNode.content?.value || "").trim()}}`;
685
- }
686
- result += '"';
687
- return result;
1188
+ this.context.write("}");
1189
+ }
1190
+ }
1191
+ visitERBIfNode(node) {
1192
+ if (this.canConvertToTernary(node)) {
1193
+ this.convertToTernary(node);
1194
+ }
1195
+ }
1196
+ visitERBUnlessNode(node) {
1197
+ if (this.canConvertUnlessToTernary(node)) {
1198
+ this.convertUnlessToTernary(node);
1199
+ }
1200
+ }
1201
+ visitHTMLAttributeValueNode(node) {
1202
+ this.visitChildNodes(node);
1203
+ }
1204
+ canConvertToTernary(node) {
1205
+ if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
1206
+ return false;
1207
+ }
1208
+ const ifOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
1209
+ if (!ifOnlyText)
1210
+ return false;
1211
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE") {
1212
+ return node.subsequent.statements
1213
+ ? node.subsequent.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
1214
+ : true;
1215
+ }
1216
+ return true;
1217
+ }
1218
+ convertToTernary(node) {
1219
+ this.context.write("#{");
1220
+ if (node.content?.value) {
1221
+ const condition = node.content.value.trim();
1222
+ const cleanCondition = condition.replace(/^if\s+/, '');
1223
+ const needsParentheses = cleanCondition.includes(' ');
1224
+ if (needsParentheses) {
1225
+ this.context.write("(");
1226
+ }
1227
+ this.context.write(cleanCondition);
1228
+ if (needsParentheses) {
1229
+ this.context.write(")");
688
1230
  }
689
1231
  }
690
- return "expression";
1232
+ this.context.write(" ? ");
1233
+ this.context.write('"');
1234
+ if (node.statements) {
1235
+ node.statements.forEach(statement => this.visit(statement));
1236
+ }
1237
+ this.context.write('"');
1238
+ this.context.write(" : ");
1239
+ this.context.write('"');
1240
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
1241
+ node.subsequent.statements.forEach(statement => this.visit(statement));
1242
+ }
1243
+ this.context.write('"');
1244
+ this.context.write("}");
1245
+ }
1246
+ convertToTernaryWithoutWrapper(node) {
1247
+ if (node.subsequent && node.subsequent.type !== "AST_ERB_ELSE_NODE") {
1248
+ return false;
1249
+ }
1250
+ if (node.content?.value) {
1251
+ const condition = node.content.value.trim();
1252
+ const cleanCondition = condition.replace(/^if\s+/, '');
1253
+ const needsParentheses = cleanCondition.includes(' ');
1254
+ if (needsParentheses) {
1255
+ this.context.write("(");
1256
+ }
1257
+ this.context.write(cleanCondition);
1258
+ if (needsParentheses) {
1259
+ this.context.write(")");
1260
+ }
1261
+ }
1262
+ this.context.write(" ? ");
1263
+ this.context.write('"');
1264
+ if (node.statements) {
1265
+ node.statements.forEach(statement => this.visit(statement));
1266
+ }
1267
+ this.context.write('"');
1268
+ this.context.write(" : ");
1269
+ this.context.write('"');
1270
+ if (node.subsequent && node.subsequent.type === "AST_ERB_ELSE_NODE" && node.subsequent.statements) {
1271
+ node.subsequent.statements.forEach(statement => this.visit(statement));
1272
+ }
1273
+ this.context.write('"');
1274
+ }
1275
+ canConvertUnlessToTernary(node) {
1276
+ const unlessOnlyText = node.statements ? node.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE") : true;
1277
+ if (!unlessOnlyText)
1278
+ return false;
1279
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
1280
+ return node.else_clause.statements
1281
+ ? node.else_clause.statements.every(statement => statement.type === "AST_HTML_TEXT_NODE")
1282
+ : true;
1283
+ }
1284
+ return true;
1285
+ }
1286
+ convertUnlessToTernary(node) {
1287
+ this.context.write("#{");
1288
+ if (node.content?.value) {
1289
+ const condition = node.content.value.trim();
1290
+ const cleanCondition = condition.replace(/^unless\s+/, '');
1291
+ const needsParentheses = cleanCondition.includes(' ');
1292
+ this.context.write("!(");
1293
+ if (needsParentheses) {
1294
+ this.context.write("(");
1295
+ }
1296
+ this.context.write(cleanCondition);
1297
+ if (needsParentheses) {
1298
+ this.context.write(")");
1299
+ }
1300
+ this.context.write(")");
1301
+ }
1302
+ this.context.write(" ? ");
1303
+ this.context.write('"');
1304
+ if (node.statements) {
1305
+ node.statements.forEach(statement => this.visit(statement));
1306
+ }
1307
+ this.context.write('"');
1308
+ this.context.write(" : ");
1309
+ this.context.write('"');
1310
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
1311
+ node.else_clause.statements.forEach(statement => this.visit(statement));
1312
+ }
1313
+ this.context.write('"');
1314
+ this.context.write("}");
1315
+ }
1316
+ convertUnlessToTernaryWithoutWrapper(node) {
1317
+ if (node.content?.value) {
1318
+ const condition = node.content.value.trim();
1319
+ const cleanCondition = condition.replace(/^unless\s+/, '');
1320
+ const needsParentheses = cleanCondition.includes(' ');
1321
+ this.context.write("!(");
1322
+ if (needsParentheses) {
1323
+ this.context.write("(");
1324
+ }
1325
+ this.context.write(cleanCondition);
1326
+ if (needsParentheses) {
1327
+ this.context.write(")");
1328
+ }
1329
+ this.context.write(")");
1330
+ }
1331
+ this.context.write(" ? ");
1332
+ this.context.write('"');
1333
+ if (node.statements) {
1334
+ node.statements.forEach(statement => this.visit(statement));
1335
+ }
1336
+ this.context.write('"');
1337
+ this.context.write(" : ");
1338
+ this.context.write('"');
1339
+ if (node.else_clause && node.else_clause.type === "AST_ERB_ELSE_NODE") {
1340
+ node.else_clause.statements.forEach(statement => this.visit(statement));
1341
+ }
1342
+ this.context.write('"');
1343
+ }
1344
+ }
1345
+
1346
+ class ERBPreferImageTagHelperVisitor extends BaseRuleVisitor {
1347
+ visitHTMLOpenTagNode(node) {
1348
+ this.checkImgTag(node);
1349
+ super.visitHTMLOpenTagNode(node);
1350
+ }
1351
+ checkImgTag(openTag) {
1352
+ const tagName = getTagName(openTag);
1353
+ if (tagName !== "img")
1354
+ return;
1355
+ const attributes = getAttributes(openTag);
1356
+ const srcAttribute = findAttributeByName(attributes, "src");
1357
+ if (!srcAttribute)
1358
+ return;
1359
+ if (!srcAttribute.value)
1360
+ return;
1361
+ const node = srcAttribute.value;
1362
+ const hasERBContent = this.containsERBContent(node);
1363
+ if (hasERBContent) {
1364
+ if (this.isDataUri(node))
1365
+ return;
1366
+ if (this.shouldFlagAsImageTagCandidate(node)) {
1367
+ const suggestedExpression = this.buildSuggestedExpression(node);
1368
+ this.addOffense(`Prefer \`image_tag\` helper over manual \`<img>\` with dynamic ERB expressions. Use \`<%= image_tag ${suggestedExpression}, alt: "..." %>\` instead.`, srcAttribute.location, "warning");
1369
+ }
1370
+ }
1371
+ }
1372
+ containsERBContent(node) {
1373
+ return core.filterNodes(node.children, core.ERBContentNode).length > 0;
1374
+ }
1375
+ isOnlyERBContent(node) {
1376
+ return node.children.length > 0 && node.children.length === core.filterNodes(node.children, core.ERBContentNode).length;
1377
+ }
1378
+ getContentofFirstChild(node) {
1379
+ if (!node.children || node.children.length === 0)
1380
+ return "";
1381
+ const firstChild = node.children[0];
1382
+ if (core.isNode(firstChild, core.LiteralNode)) {
1383
+ return (firstChild.content || "").trim();
1384
+ }
1385
+ return "";
1386
+ }
1387
+ isDataUri(node) {
1388
+ return this.getContentofFirstChild(node).startsWith("data:");
1389
+ }
1390
+ isFullUrl(node) {
1391
+ const content = this.getContentofFirstChild(node);
1392
+ return content.startsWith("http://") || content.startsWith("https://");
1393
+ }
1394
+ shouldFlagAsImageTagCandidate(node) {
1395
+ if (this.isOnlyERBContent(node))
1396
+ return true;
1397
+ if (this.isFullUrl(node))
1398
+ return false;
1399
+ return true;
1400
+ }
1401
+ buildSuggestedExpression(node) {
1402
+ if (!node.children)
1403
+ return "expression";
1404
+ try {
1405
+ return ERBToRubyStringPrinter.print(node, { ignoreErrors: false });
1406
+ }
1407
+ catch (error) {
1408
+ return "expression";
1409
+ }
691
1410
  }
692
1411
  }
693
1412
  class ERBPreferImageTagHelperRule extends ParserRule {
@@ -1299,19 +2018,141 @@ class HTMLNoDuplicateAttributesRule extends ParserRule {
1299
2018
  }
1300
2019
  }
1301
2020
 
1302
- class NoDuplicateIdsVisitor extends AttributeVisitorMixin {
2021
+ class OutputPrinter extends Printer {
2022
+ visitLiteralNode(node) {
2023
+ this.write(IdentityPrinter.print(node));
2024
+ }
2025
+ visitERBContentNode(node) {
2026
+ if (core.isERBOutputNode(node)) {
2027
+ this.write(IdentityPrinter.print(node));
2028
+ }
2029
+ }
2030
+ }
2031
+ class NoDuplicateIdsVisitor extends ControlFlowTrackingVisitor {
1303
2032
  documentIds = new Set();
1304
- checkStaticAttributeStaticValue({ attributeName, attributeValue, attributeNode }) {
1305
- if (attributeName.toLowerCase() !== "id")
2033
+ currentBranchIds = new Set();
2034
+ controlFlowIds = new Set();
2035
+ visitHTMLAttributeNode(node) {
2036
+ this.checkAttribute(node);
2037
+ }
2038
+ onEnterControlFlow(_controlFlowType, wasAlreadyInControlFlow) {
2039
+ const stateToRestore = {
2040
+ previousBranchIds: this.currentBranchIds,
2041
+ previousControlFlowIds: this.controlFlowIds
2042
+ };
2043
+ this.currentBranchIds = new Set();
2044
+ if (!wasAlreadyInControlFlow) {
2045
+ this.controlFlowIds = new Set();
2046
+ }
2047
+ return stateToRestore;
2048
+ }
2049
+ onExitControlFlow(controlFlowType, wasAlreadyInControlFlow, stateToRestore) {
2050
+ if (controlFlowType === ControlFlowType.CONDITIONAL && !wasAlreadyInControlFlow) {
2051
+ this.controlFlowIds.forEach(id => this.documentIds.add(id));
2052
+ }
2053
+ this.currentBranchIds = stateToRestore.previousBranchIds;
2054
+ this.controlFlowIds = stateToRestore.previousControlFlowIds;
2055
+ }
2056
+ onEnterBranch() {
2057
+ const stateToRestore = {
2058
+ previousBranchIds: this.currentBranchIds
2059
+ };
2060
+ if (this.isInControlFlow) {
2061
+ this.currentBranchIds = new Set();
2062
+ }
2063
+ return stateToRestore;
2064
+ }
2065
+ onExitBranch(_stateToRestore) { }
2066
+ checkAttribute(attributeNode) {
2067
+ if (!this.isIdAttribute(attributeNode))
1306
2068
  return;
1307
- if (!attributeValue)
2069
+ const idValue = this.extractIdValue(attributeNode);
2070
+ if (!idValue)
1308
2071
  return;
1309
- const id = attributeValue.trim();
1310
- if (this.documentIds.has(id)) {
1311
- this.addOffense(`Duplicate ID \`${id}\` found. IDs must be unique within a document.`, attributeNode.location, "error");
2072
+ if (this.isWhitespaceOnlyId(idValue.identifier))
1312
2073
  return;
2074
+ this.processIdDuplicate(idValue, attributeNode);
2075
+ }
2076
+ isIdAttribute(attributeNode) {
2077
+ if (!attributeNode.name?.children || !attributeNode.value)
2078
+ return false;
2079
+ return core.getStaticAttributeName(attributeNode.name) === "id";
2080
+ }
2081
+ extractIdValue(attributeNode) {
2082
+ const valueNodes = attributeNode.value?.children || [];
2083
+ if (core.hasERBOutput(valueNodes) && this.isInControlFlow && this.currentControlFlowType === ControlFlowType.LOOP) {
2084
+ return null;
1313
2085
  }
1314
- this.documentIds.add(id);
2086
+ const identifier = core.isEffectivelyStatic(valueNodes) ? core.getValidatableStaticContent(valueNodes) : OutputPrinter.print(valueNodes);
2087
+ if (!identifier)
2088
+ return null;
2089
+ return { identifier, shouldTrackDuplicates: true };
2090
+ }
2091
+ isWhitespaceOnlyId(identifier) {
2092
+ return identifier !== '' && identifier.trim() === '';
2093
+ }
2094
+ processIdDuplicate(idValue, attributeNode) {
2095
+ const { identifier, shouldTrackDuplicates } = idValue;
2096
+ if (!shouldTrackDuplicates)
2097
+ return;
2098
+ if (this.isInControlFlow) {
2099
+ this.handleControlFlowId(identifier, attributeNode);
2100
+ }
2101
+ else {
2102
+ this.handleGlobalId(identifier, attributeNode);
2103
+ }
2104
+ }
2105
+ handleControlFlowId(identifier, attributeNode) {
2106
+ if (this.currentControlFlowType === ControlFlowType.LOOP) {
2107
+ this.handleLoopId(identifier, attributeNode);
2108
+ }
2109
+ else {
2110
+ this.handleConditionalId(identifier, attributeNode);
2111
+ }
2112
+ this.currentBranchIds.add(identifier);
2113
+ }
2114
+ handleLoopId(identifier, attributeNode) {
2115
+ const isStaticId = this.isStaticId(attributeNode);
2116
+ if (isStaticId) {
2117
+ this.addDuplicateIdOffense(identifier, attributeNode.location);
2118
+ return;
2119
+ }
2120
+ if (this.currentBranchIds.has(identifier)) {
2121
+ this.addSameLoopIterationOffense(identifier, attributeNode.location);
2122
+ }
2123
+ }
2124
+ handleConditionalId(identifier, attributeNode) {
2125
+ if (this.currentBranchIds.has(identifier)) {
2126
+ this.addSameBranchOffense(identifier, attributeNode.location);
2127
+ return;
2128
+ }
2129
+ if (this.documentIds.has(identifier)) {
2130
+ this.addDuplicateIdOffense(identifier, attributeNode.location);
2131
+ return;
2132
+ }
2133
+ this.controlFlowIds.add(identifier);
2134
+ }
2135
+ handleGlobalId(identifier, attributeNode) {
2136
+ if (this.documentIds.has(identifier)) {
2137
+ this.addDuplicateIdOffense(identifier, attributeNode.location);
2138
+ return;
2139
+ }
2140
+ this.documentIds.add(identifier);
2141
+ }
2142
+ isStaticId(attributeNode) {
2143
+ const valueNodes = attributeNode.value.children;
2144
+ const isCompletelyStatic = valueNodes.every(child => core.isNode(child, core.LiteralNode));
2145
+ const isEffectivelyStaticValue = core.isEffectivelyStatic(valueNodes);
2146
+ return isCompletelyStatic || isEffectivelyStaticValue;
2147
+ }
2148
+ addDuplicateIdOffense(identifier, location) {
2149
+ this.addOffense(`Duplicate ID \`${identifier}\` found. IDs must be unique within a document.`, location, "error");
2150
+ }
2151
+ addSameLoopIterationOffense(identifier, location) {
2152
+ this.addOffense(`Duplicate ID \`${identifier}\` found within the same loop iteration. IDs must be unique within the same loop iteration.`, location, "error");
2153
+ }
2154
+ addSameBranchOffense(identifier, location) {
2155
+ this.addOffense(`Duplicate ID \`${identifier}\` found within the same control flow branch. IDs must be unique within the same control flow branch.`, location, "error");
1315
2156
  }
1316
2157
  }
1317
2158
  class HTMLNoDuplicateIdsRule extends ParserRule {
@@ -1513,14 +2354,20 @@ class HTMLNoPositiveTabIndexRule extends ParserRule {
1513
2354
  }
1514
2355
 
1515
2356
  class NoSelfClosingVisitor extends BaseRuleVisitor {
2357
+ visitHTMLElementNode(node) {
2358
+ if (core.getTagName(node) === "svg") {
2359
+ this.visit(node.open_tag);
2360
+ }
2361
+ else {
2362
+ this.visitChildNodes(node);
2363
+ }
2364
+ }
1516
2365
  visitHTMLOpenTagNode(node) {
1517
2366
  if (node.tag_closing?.value === "/>") {
1518
- const tagName = getTagName(node);
1519
- const shouldBeVoid = tagName ? isVoidElement(tagName) : false;
1520
- const instead = shouldBeVoid ? `Use \`<${tagName}>\` instead.` : `Use \`<${tagName}></${tagName}>\` instead.`;
1521
- this.addOffense(`Self-closing syntax \`<${tagName} />\` is not allowed in HTML. ${instead}`, node.location, "error");
2367
+ const tagName = core.getTagName(node);
2368
+ const instead = isVoidElement(tagName) ? `<${tagName}>` : `<${tagName}></${tagName}>`;
2369
+ this.addOffense(`Use \`${instead}\` instead of self-closing \`<${tagName} />\` for HTML compatibility.`, node.location, "error");
1522
2370
  }
1523
- super.visitHTMLOpenTagNode(node);
1524
2371
  }
1525
2372
  }
1526
2373
  class HTMLNoSelfClosingRule extends ParserRule {