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