@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/herb-lint.js +1334 -128
- package/dist/herb-lint.js.map +1 -1
- package/dist/index.cjs +919 -72
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +920 -73
- package/dist/index.js.map +1 -1
- package/dist/package.json +5 -4
- package/dist/src/rules/erb-prefer-image-tag-helper.js +50 -60
- package/dist/src/rules/erb-prefer-image-tag-helper.js.map +1 -1
- package/dist/src/rules/html-no-duplicate-ids.js +134 -9
- package/dist/src/rules/html-no-duplicate-ids.js.map +1 -1
- package/dist/src/rules/html-no-self-closing.js +12 -5
- package/dist/src/rules/html-no-self-closing.js.map +1 -1
- package/dist/src/rules/rule-utils.js +69 -0
- package/dist/src/rules/rule-utils.js.map +1 -1
- package/dist/types/rules/rule-utils.d.ts +39 -0
- package/dist/types/src/rules/rule-utils.d.ts +39 -0
- package/package.json +5 -4
- package/src/rules/erb-prefer-image-tag-helper.ts +53 -71
- package/src/rules/html-no-duplicate-ids.ts +188 -14
- package/src/rules/html-no-self-closing.ts +13 -8
- package/src/rules/rule-utils.ts +97 -0
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
|
|
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
|
-
|
|
618
|
-
|
|
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
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
return;
|
|
859
|
+
visitHTMLCloseTagNode(node) {
|
|
860
|
+
if (node.tag_opening) {
|
|
861
|
+
this.write(node.tag_opening.value);
|
|
624
862
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
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
|
-
|
|
631
|
-
|
|
870
|
+
else {
|
|
871
|
+
this.visitAll(node.children);
|
|
632
872
|
}
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
646
|
-
if (
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
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
|
-
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
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
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
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
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1305
|
-
|
|
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
|
-
|
|
2069
|
+
const idValue = this.extractIdValue(attributeNode);
|
|
2070
|
+
if (!idValue)
|
|
1308
2071
|
return;
|
|
1309
|
-
|
|
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
|
-
|
|
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
|
|
1520
|
-
|
|
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 {
|