Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1523,11 +1523,14 @@ class CodeEditor extends Component<Props, State> {
return;
}

const lintErrors = this.props.lintErrors;

if (this.props.customErrors?.length) {
lintErrors.push(...this.props.customErrors);
}
// Copy before merging — getEntityLintErrors may return a shared emptyLint
// singleton. Mutating it with push() permanently pollutes that array, so
// custom widget compile errors survive after the syntax is fixed and can
// underline the wrong line once the source shifts.
const lintErrors = [
...this.props.lintErrors,
...(this.props.customErrors ?? []),
];

this.annotations = getLintAnnotations(editor.getValue(), lintErrors, {
isJSObject,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
IDENTIFIER_NOT_DEFINED_LINT_ERROR_CODE,
INVALID_JSOBJECT_START_STATEMENT,
INVALID_JSOBJECT_START_STATEMENT_ERROR_CODE,
LINT_BINDING_LITERAL_MATCH_ERROR_CODE,
} from "plugins/Linting/constants";
export const getIndexOfRegex = (
str: string,
Expand Down Expand Up @@ -74,11 +75,33 @@ export const getAllWordOccurrences = (str: string, key: string) => {
return indices;
};

/** indexOf-based search — no word-boundary regex (safe for mid-word prefixes). */
export const getAllLiteralOccurrences = (str: string, key: string) => {
if (!key) {
return [];
}

const indices = [];
let startIndex = 0;
let index = str.indexOf(key, startIndex);

while (index > -1) {
indices.push(index);
startIndex = index + key.length;
index = str.indexOf(key, startIndex);
}

return indices;
};

export const getKeyPositionInString = (
str: string,
key: string,
options?: { literal?: boolean },
): Position[] => {
const indices = getAllWordOccurrences(str, key);
const indices = options?.literal
? getAllLiteralOccurrences(str, key)
: getAllWordOccurrences(str, key);
let positions: Position[] = [];

if (str.includes("\n")) {
Expand Down Expand Up @@ -178,7 +201,9 @@ export const getLintAnnotations = (

const bindingPositions = isJSObject
? [VALID_JS_OBJECT_BINDING_POSITION]
: getKeyPositionInString(value, originalBinding);
: getKeyPositionInString(value, originalBinding, {
literal: code === LINT_BINDING_LITERAL_MATCH_ERROR_CODE,
});

if (isNumber(line) && isNumber(ch)) {
for (const bindingLocation of bindingPositions) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,10 @@ import {
import { CustomWidgetBuilderContext } from "../..";
import LazyCodeEditor from "components/editorComponents/LazyCodeEditor";
import { getAppsmithScriptSchema } from "widgets/CustomWidget/component/constants";
// import { DebuggerLogType } from "../../types";
// import { PropertyEvaluationErrorType } from "utils/DynamicBindingUtils";
// import { Severity } from "entities/AppsmithConsole";
import CodemirrorTernService from "utils/autocomplete/CodemirrorTernService";
import { Spinner } from "@appsmith/ads";
import { CUSTOM_WIDGET_FEATURE, createMessage } from "ee/constants/messages";
import { DebuggerLogType } from "../../types";
import type { LintError } from "utils/DynamicBindingUtils";
import { PropertyEvaluationErrorType } from "utils/DynamicBindingUtils";
import { Severity } from "entities/AppsmithConsole";
import { isUndefined } from "lodash";
import { mapCompileErrorsToLintErrors } from "./mapCompileErrorsToLintErrors";

export default function JSEditor(props: ContentProps) {
const [loading, setLoading] = useState(true);
Expand All @@ -36,30 +29,11 @@ export default function JSEditor(props: ContentProps) {

const { height } = props;

const errors: LintError[] = useMemo(() => {
return debuggerLogs
? debuggerLogs
.filter((d) => d.type === DebuggerLogType.ERROR)
.map((d) => d.args)
.flat()
.filter((d) => !isUndefined(d.line) && !isUndefined(d.column))
.map((d) => ({
errorType: PropertyEvaluationErrorType.LINT,
raw: uncompiledSrcDoc?.js || "",
severity: Severity.ERROR,
errorMessage: {
name: "LintingError",
message: d.message as string,
},
errorSegment: uncompiledSrcDoc?.js || "",
originalBinding: uncompiledSrcDoc?.js || "",
variables: [],
code: "",
line: d.line ? d.line - 1 : 1,
ch: d.column ? d.column + 2 : 1,
}))
: [];
}, [debuggerLogs]);
const errors = useMemo(
() =>
mapCompileErrorsToLintErrors(debuggerLogs, uncompiledSrcDoc?.js || ""),
[debuggerLogs, uncompiledSrcDoc?.js],
);

useEffect(() => {
["LIB/node-forge", "LIB/moment", "base64-js", "LIB/lodash"].forEach((d) => {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { getLintAnnotations } from "components/editorComponents/CodeEditor/lintHelpers";
import { LINT_BINDING_LITERAL_MATCH_ERROR_CODE } from "plugins/Linting/constants";
import { DebuggerLogType } from "../../types";
import {
getLintBindingPrefix,
LINT_BINDING_PREFIX_LENGTH,
mapCompileErrorsToLintErrors,
} from "./mapCompileErrorsToLintErrors";

describe("getLintBindingPrefix", () => {
it("returns a space for empty source", () => {
expect(getLintBindingPrefix("")).toEqual({
value: " ",
useLiteralMatch: false,
});
});

it("returns the full string when shorter than the prefix length", () => {
const js = "const a = 1;";

expect(getLintBindingPrefix(js)).toEqual({
value: js,
useLiteralMatch: false,
});
});

it("truncates to at most LINT_BINDING_PREFIX_LENGTH", () => {
const js = "a".repeat(LINT_BINDING_PREFIX_LENGTH + 50);
const result = getLintBindingPrefix(js);

expect(result.value.length).toBeLessThanOrEqual(LINT_BINDING_PREFIX_LENGTH);
expect(result.useLiteralMatch).toBe(true);
});

it("trims a mid-word cut so the prefix still matches at index 0", () => {
const js =
'import React from "https://esm.sh/react@18.2.0";\n' +
"const filler = `" +
"x".repeat(400) +
"`;\n";
const { useLiteralMatch, value: prefix } = getLintBindingPrefix(js);

expect(js.startsWith(prefix)).toBe(true);
expect(prefix.length).toBeLessThanOrEqual(LINT_BINDING_PREFIX_LENGTH);
// Must not end mid-run of x's while more x's follow
expect(prefix.endsWith("x")).toBe(false);
expect(useLiteralMatch).toBe(false);
});

it("opts into literal match when the first 128 chars are one unbroken word", () => {
const js = "a".repeat(LINT_BINDING_PREFIX_LENGTH + 200) + "\nfunction\n";
const result = getLintBindingPrefix(js);

expect(result.value).toBe("a".repeat(LINT_BINDING_PREFIX_LENGTH));
expect(result.useLiteralMatch).toBe(true);
});
});

describe("mapCompileErrorsToLintErrors", () => {
const babelError = {
line: 10,
column: 4,
message: "SyntaxError: Unexpected token",
};

it("returns an empty array when debuggerLogs is undefined", () => {
expect(mapCompileErrorsToLintErrors(undefined, "const a = 1;")).toEqual([]);
});

it("maps located compile errors with a short binding prefix", () => {
const largeJs =
'import React from "https://esm.sh/react@18.2.0";\n' +
"const filler = `" +
"x".repeat(40000) +
"`;\nfunction\n";

const mapped = mapCompileErrorsToLintErrors(
[
{
type: DebuggerLogType.ERROR,
args: [babelError],
},
],
largeJs,
);

expect(mapped).toHaveLength(1);
expect(mapped[0].originalBinding.length).toBeLessThanOrEqual(
LINT_BINDING_PREFIX_LENGTH,
);
expect(mapped[0].originalBinding).not.toEqual(largeJs);
expect(mapped[0].raw).toEqual(mapped[0].originalBinding);
expect(mapped[0].errorSegment).toEqual(mapped[0].originalBinding);
expect(mapped[0].line).toBe(9);
expect(mapped[0].ch).toBe(6);
expect(mapped[0].code).toBe("");
});

it("does not throw in getLintAnnotations for a large source with a syntax error", () => {
const largeJs =
'import React from "https://esm.sh/react@18.2.0";\n' +
"const filler = `" +
"x".repeat(40000) +
"`;\nfunction\n";

const mapped = mapCompileErrorsToLintErrors(
[
{
type: DebuggerLogType.ERROR,
args: [{ line: 3, column: 0, message: "Unexpected token" }],
},
],
largeJs,
);

expect(() => getLintAnnotations(largeJs, mapped, {})).not.toThrow();

const annotations = getLintAnnotations(largeJs, mapped, {});

expect(annotations.length).toBeGreaterThan(0);
expect(annotations[0].from?.line).toBe(2);
});

it("still produces an annotation for a small default-template sized source", () => {
const smallJs = `import React from "https://esm.sh/react@18.2.0";
import ReactDOM from "https://esm.sh/react-dom@18.2.0";

function App() {
return <div>hi</div>;
}

appsmith.onReady(() => {
ReactDOM.render(<App />, document.getElementById("root"));
});
function
`;

const mapped = mapCompileErrorsToLintErrors(
[
{
type: DebuggerLogType.ERROR,
args: [{ line: 11, column: 0, message: "Unexpected token" }],
},
],
smallJs,
);

expect(mapped[0].originalBinding.length).toBeLessThanOrEqual(
LINT_BINDING_PREFIX_LENGTH,
);

const annotations = getLintAnnotations(smallJs, mapped, {});

expect(annotations.length).toBeGreaterThan(0);
expect(annotations[0].from?.line).toBe(10);
});

it("produces an underline when the source starts with more than 128 word characters", () => {
const js = "a".repeat(LINT_BINDING_PREFIX_LENGTH + 200) + "\nfunction\n";
const errorLine = 2; // 1-based Babel line of `function`

const mapped = mapCompileErrorsToLintErrors(
[
{
type: DebuggerLogType.ERROR,
args: [{ line: errorLine, column: 0, message: "Unexpected token" }],
},
],
js,
);

expect(mapped[0].code).toBe(LINT_BINDING_LITERAL_MATCH_ERROR_CODE);
expect(mapped[0].originalBinding.length).toBe(LINT_BINDING_PREFIX_LENGTH);

const annotations = getLintAnnotations(js, mapped, {});

expect(annotations.length).toBeGreaterThan(0);
expect(annotations[0].from?.line).toBe(errorLine - 1);
});

it("skips errors without line or column", () => {
const mapped = mapCompileErrorsToLintErrors(
[
{
type: DebuggerLogType.ERROR,
args: [{ message: "no location" }],
},
],
"const a = 1;",
);

expect(mapped).toEqual([]);
});
});
Loading
Loading