Skip to content

Commit

Permalink
Add analysis rule to check regex pattern validity (#4904)
Browse files Browse the repository at this point in the history
  • Loading branch information
twschiller committed Dec 28, 2022
1 parent 067fe1c commit d2dde82
Show file tree
Hide file tree
Showing 3 changed files with 229 additions and 0 deletions.
97 changes: 97 additions & 0 deletions src/analysis/analysisVisitors/regexAnalysis.ts
@@ -0,0 +1,97 @@
/*
* Copyright (C) 2022 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { AnalysisVisitor } from "./baseAnalysisVisitors";
import { AnnotationType } from "@/analysis/analysisTypes";
import { type BlockConfig, type BlockPosition } from "@/blocks/types";
import { type VisitBlockExtra } from "@/blocks/PipelineVisitor";
import { validateRegistryId } from "@/types/helpers";
import { isTemplateExpression } from "@/runtime/mapArgs";
import { getErrorMessage } from "@/errors/errorHelpers";
import { joinPathParts } from "@/utils";

function containsTemplateExpression(literalOrTemplate: string): boolean {
return literalOrTemplate.includes("{{") || literalOrTemplate.includes("{%");
}

class RegexAnalysis extends AnalysisVisitor {
get id() {
return "regex";
}

override visitBlock(
position: BlockPosition,
blockConfig: BlockConfig,
extra: VisitBlockExtra
) {
super.visitBlock(position, blockConfig, extra);

if (blockConfig.id !== validateRegistryId("@pixiebrix/regex")) {
return;
}

const { regex: rawRegex = "" } = blockConfig.config;
let pattern: string;
if (typeof rawRegex === "string") {
pattern = rawRegex;
} else if (
isTemplateExpression(rawRegex) &&
rawRegex.__type__ === "nunjucks" &&
!containsTemplateExpression(rawRegex.__value__)
) {
pattern = rawRegex.__value__;
} else {
// Skip variables and dynamic expressions
return;
}

let compileError;

try {
// eslint-disable-next-line no-new -- evaluating for type error
new RegExp(pattern);
} catch (error) {
compileError = error;
}

// Create new regex on each analysis call to avoid state issues with test
const namedCapturedGroupRegex = /\(\?<\S+>.*?\)/g;

if (compileError) {
this.annotations.push({
position: {
path: joinPathParts(position.path, "config", "regex"),
},
message: getErrorMessage(compileError),
analysisId: this.id,
type: AnnotationType.Error,
});
} else if (!namedCapturedGroupRegex.test(pattern)) {
this.annotations.push({
position: {
path: joinPathParts(position.path, "config", "regex"),
},
message:
"Expected regular expression to contain at least one named capture group: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Groups_and_Backreferences",
analysisId: this.id,
type: AnnotationType.Warning,
});
}
}
}

export default RegexAnalysis;
127 changes: 127 additions & 0 deletions src/analysis/analysisVisitors/regexAnalysisTest.test.ts
@@ -0,0 +1,127 @@
/*
* Copyright (C) 2022 PixieBrix, Inc.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { type BlockPosition } from "@/blocks/types";
import RegexAnalysis from "@/analysis/analysisVisitors/regexAnalysis";
import { validateRegistryId } from "@/types/helpers";
import { type VisitBlockExtra } from "@/blocks/PipelineVisitor";

const position: BlockPosition = {
path: "test.path",
};

describe("RegexAnalysis", () => {
test("ignore expression", () => {
const analysis = new RegexAnalysis();
analysis.visitBlock(
position,
{
id: validateRegistryId("@pixiebrix/regex"),
config: {
regex: {
__type__: "nunjucks",
__value__: "(?<foo>abc {{ @foo }}",
},
},
},
{} as VisitBlockExtra
);

expect(analysis.getAnnotations()).toHaveLength(0);
});

test("validate string literal", () => {
const analysis = new RegexAnalysis();
analysis.visitBlock(
position,
{
id: validateRegistryId("@pixiebrix/regex"),
config: {
regex: "(?<foo>abc",
},
},
{} as VisitBlockExtra
);

expect(analysis.getAnnotations()).toHaveLength(1);
});

test("error on invalid regex", () => {
const analysis = new RegexAnalysis();
analysis.visitBlock(
position,
{
id: validateRegistryId("@pixiebrix/regex"),
config: {
regex: {
__type__: "nunjucks",
__value__: "(?<foo>abc",
},
},
},
{} as VisitBlockExtra
);

expect(analysis.getAnnotations()).toHaveLength(1);
expect(analysis.getAnnotations()[0].message).toEqual(
"Invalid regular expression: /(?<foo>abc/: Unterminated group"
);
});

test("warns on missing regex named capture group", () => {
const analysis = new RegexAnalysis();
analysis.visitBlock(
position,
{
id: validateRegistryId("@pixiebrix/regex"),
config: {
regex: {
__type__: "nunjucks",
__value__: "^bar$",
},
},
},
{} as VisitBlockExtra
);

expect(analysis.getAnnotations()).toHaveLength(1);
});

test.each([
["^(?<foo>bar)$"],
["^(?<foo>bar)(?<bar>baz)$"],
["^(?<foo>bar)after-group"],
["before-group(?<foo>bar)"],
])("accept value pattern: %s", (pattern) => {
const analysis = new RegexAnalysis();
analysis.visitBlock(
position,
{
id: validateRegistryId("@pixiebrix/regex"),
config: {
regex: {
__type__: "nunjucks",
__value__: pattern,
},
},
},
{} as VisitBlockExtra
);

expect(analysis.getAnnotations()).toHaveLength(0);
});
});
5 changes: 5 additions & 0 deletions src/pageEditor/analysisManager.ts
Expand Up @@ -34,6 +34,7 @@ import { selectActiveElementTraces } from "./slices/runtimeSelectors";
import VarAnalysis from "@/analysis/analysisVisitors/varAnalysis/varAnalysis";
import analysisSlice from "@/analysis/analysisSlice";
import { selectSettings } from "@/store/settingsSelectors";
import RegexAnalysis from "@/analysis/analysisVisitors/regexAnalysis";

const runtimeActions = runtimeSlice.actions;

Expand Down Expand Up @@ -112,6 +113,10 @@ pageEditorAnalysisManager.registerAnalysisEffect(
}
);

pageEditorAnalysisManager.registerAnalysisEffect(() => new RegexAnalysis(), {
matcher: isAnyOf(editorActions.editElement, ...nodeListMutationActions),
});

const varAnalysisFactory = (
action: PayloadAction<{ extensionId: UUID; records: TraceRecord[] }>,
state: RootState
Expand Down

0 comments on commit d2dde82

Please sign in to comment.