@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.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Visitor, Position, Location, getStaticAttributeName, hasERBOutput, isEffectivelyStatic, getValidatableStaticContent, hasDynamicAttributeName as hasDynamicAttributeName$1, getCombinedAttributeName, filterERBContentNodes,
|
|
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
|
|
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
|
-
|
|
616
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
return;
|
|
857
|
+
visitHTMLCloseTagNode(node) {
|
|
858
|
+
if (node.tag_opening) {
|
|
859
|
+
this.write(node.tag_opening.value);
|
|
622
860
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
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
|
-
|
|
629
|
-
|
|
868
|
+
else {
|
|
869
|
+
this.visitAll(node.children);
|
|
630
870
|
}
|
|
631
|
-
|
|
632
|
-
|
|
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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
644
|
-
if (
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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
|
-
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
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
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
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
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1303
|
-
|
|
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
|
-
|
|
2067
|
+
const idValue = this.extractIdValue(attributeNode);
|
|
2068
|
+
if (!idValue)
|
|
1306
2069
|
return;
|
|
1307
|
-
|
|
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
|
-
|
|
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
|
|
1518
|
-
|
|
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 {
|