feat: show lint errors in async functions bound to sync fields (#21187)

## Description

This PR improves the error resolution journey for users. Lint warnings
are added to async JS functions which are bound to data fields (sync
fields).

- JSObjects are "linted" by individual properties (as opposed to being
"linted" as a whole)
- Only edited jsobject properties get "linted", improving jsObject
linting by ~35%.(This largely depends on the size of the JSObject)
<img width="500" alt="Screenshot 2023-04-03 at 11 17 45"
src="https://user-images.githubusercontent.com/46670083/229482424-233f3950-ffec-46f5-8c42-680dff6a412f.png">
<img width="500" alt="Screenshot 2023-03-14 at 11 26 00"
src="https://user-images.githubusercontent.com/46670083/224975572-b2d8d404-aac6-43fb-be14-20edf7c56117.png">
<img width="500" alt="Screenshot 2023-03-14 at 11 41 11"
src="https://user-images.githubusercontent.com/46670083/224975952-c40848b1-69d8-489d-9b62-24127ea1a2f1.png">

Fixes #20289
Fixes #20008


## Type of change

- Bug fix (non-breaking change which fixes an issue)


## How Has This Been Tested?

- CYPRESS
- JEST

### Test Plan
> Add Testsmith test cases links that relate to this PR

### Issues raised during DP testing
> Link issues raised during DP testing for better visiblity and tracking
(copy link from comments dropped on this PR)


## Checklist:
### Dev activity
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my own code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] PR is being merged under a feature flag


### QA activity:
- [ ] Test plan has been approved by relevant developers
- [x] Test plan has been peer reviewed by QA
- [x] Cypress test cases have been added and approved by either SDET or
manual QA
- [ ] Organized project review call with relevant stakeholders after
Round 1/2 of QA
- [ ] Added Test Plan Approved label after reveiwing all Cypress test
This commit is contained in:
Favour Ohanekwu 2023-04-03 11:41:15 +01:00 committed by GitHub
parent 1b92f97d61
commit b80b0ca3fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
51 changed files with 4383 additions and 1360 deletions

View File

@ -1,6 +1,5 @@
const dsl = require("../../../../fixtures/autocomp.json");
const dynamicInputLocators = require("../../../../locators/DynamicInput.json");
const apiwidget = require("../../../../locators/apiWidgetslocator.json");
describe("Dynamic input autocomplete", () => {
before(() => {
@ -72,7 +71,7 @@ describe("Dynamic input autocomplete", () => {
cy.wait(1000);
cy.evaluateErrorMessage(
"Found a reference to {{actionName}} during evaluation. Sync fields cannot execute framework actions. Please remove any direct/indirect references to {{actionName}} and try again.".replaceAll(
"Found a reference to {{actionName}} during evaluation. Data fields cannot execute framework actions. Please remove any direct/indirect references to {{actionName}} and try again.".replaceAll(
"{{actionName}}",
"storeValue()",
),

View File

@ -0,0 +1,130 @@
import * as _ from "../../../../support/Objects/ObjectsCore";
describe("Linting async JSFunctions bound to data fields", () => {
before(() => {
_.entityExplorer.DragDropWidgetNVerify("buttonwidget", 300, 300);
_.entityExplorer.NavigateToSwitcher("explorer");
});
it("1. Doesn't show lint warnings in debugger but shows on Hover only", () => {
_.apiPage.CreateApi();
const JS_OBJECT_CONTENT = `export default {
myFun1: () => {
//write code here
Api1.run()
},
myFun2: async () => {
//use async-await or promises
}
}`;
_.jsEditor.CreateJSObject(JS_OBJECT_CONTENT, {
paste: true,
completeReplace: true,
toRun: false,
shouldCreateNewJSObj: true,
});
_.entityExplorer.SelectEntityByName("Button1", "Widgets");
_.propPane.UpdatePropertyFieldValue("Label", "{{JSObject1.myFun2()}}");
cy.get(_.locators._evaluateMsg).should("be.visible");
cy.contains("View Source").click(); // should route to jsobject page
cy.get(_.locators._lintWarningElement).should("have.length", 1);
MouseHoverNVerify(
"myFun2",
`Cannot bind async functions to data fields. Convert this to a sync function or remove references to "JSObject1.myFun2" on the following data field: Button1.text`,
false,
);
// remove async tag from function
_.jsEditor.EditJSObj(`export default {
myFun1: () => {
//write code here
Api1.run()
},
myFun2: () => {
//use async-await or promises
}
}`);
cy.get(_.locators._lintWarningElement).should("not.exist");
// Add async tag from function
_.jsEditor.EditJSObj(`export default {
myFun1: () => {
//write code here
Api1.run()
},
myFun2: async () => {
//use async-await or promises
}
}`);
cy.get(_.locators._lintWarningElement).should("have.length", 1);
MouseHoverNVerify(
"myFun2",
`Cannot bind async functions to data fields. Convert this to a sync function or remove references to "JSObject1.myFun2" on the following data field: Button1.text`,
false,
);
_.entityExplorer.SelectEntityByName("Button1", "Widgets");
_.propPane.UpdatePropertyFieldValue("Label", "{{JSObject1.myFun1()}}");
cy.get(_.locators._evaluateMsg).should("be.visible");
cy.contains("View Source").click(); // should route to jsobject page
cy.get(_.locators._lintWarningElement).should("have.length", 2);
MouseHoverNVerify(
"myFun1",
`Functions bound to data fields cannot execute async code. Remove async statements highlighted below or remove references to "JSObject1.myFun1" on the following data field: Button1.text`,
false,
);
MouseHoverNVerify(
"run",
`Cannot execute async code on functions bound to data fields`,
false,
);
_.jsEditor.EditJSObj(`export default {
myFun1: () => {
//write code here
Api1.run()
},
myFun2: async () => {
//use async-await or promises
}
}`);
// Remove binding from label, and add to onClick. Expect no error
_.entityExplorer.SelectEntityByName("Button1", "Widgets");
_.propPane.UpdatePropertyFieldValue("Label", "Click here");
_.propPane.EnterJSContext(
"onClick",
`{{
() => {
JSObject1.myFun1();
JSObject1.myFun2()
}}}`,
);
_.entityExplorer.ExpandCollapseEntity("Queries/JS");
_.entityExplorer.SelectEntityByName("JSObject1", "Queries/JS");
cy.get(_.locators._lintWarningElement).should("not.exist");
});
function MouseHoverNVerify(lintOn: string, debugMsg: string, isError = true) {
_.agHelper.Sleep();
const element = isError
? cy.get(_.locators._lintErrorElement)
: cy.get(_.locators._lintWarningElement);
element.contains(lintOn).should("exist").first().trigger("mouseover");
_.agHelper.AssertContains(debugMsg);
}
after(() => {
//deleting all test data
_.entityExplorer.ActionContextMenuByEntityName(
"Api1",
"Delete",
"Are you sure?",
);
_.entityExplorer.ActionContextMenuByEntityName(
"JSObject1",
"Delete",
"Are you sure?",
);
});
});

View File

@ -176,5 +176,6 @@ export class CommonLocators {
_commentString = ".cm-comment";
_modalWrapper = "[data-cy='modal-wrapper']";
_editorBackButton = ".t--close-editor";
_evaluateMsg = ".t--evaluatedPopup-error";
_canvas = "[data-testid=widgets-editor]";
}

View File

@ -64,9 +64,7 @@
"@uppy/url": "^1.5.16",
"@uppy/webcam": "^1.8.4",
"@welldone-software/why-did-you-render": "^4.2.5",
"acorn-walk": "^8.2.0",
"algoliasearch": "^4.2.0",
"astring": "^1.7.5",
"axios": "^0.27.2",
"classnames": "^2.3.1",
"codemirror": "^5.59.2",

View File

@ -1,11 +1,11 @@
import type { LintErrorsStore } from "reducers/lintingReducers/lintErrorsReducers";
import type { ReduxAction } from "@appsmith/constants/ReduxActionConstants";
import { ReduxActionTypes } from "@appsmith/constants/ReduxActionConstants";
import type { LintErrors } from "reducers/lintingReducers/lintErrorsReducers";
export type SetLintErrorsAction = ReduxAction<{ errors: LintErrors }>;
export type SetLintErrorsAction = ReduxAction<{ errors: LintErrorsStore }>;
export const setLintingErrors = (
errors: LintErrors,
): ReduxAction<{ errors: LintErrors }> => {
errors: LintErrorsStore,
): ReduxAction<{ errors: LintErrorsStore }> => {
return {
type: ReduxActionTypes.SET_LINT_ERRORS,
payload: { errors },

View File

@ -68,7 +68,7 @@ import type { EditorContextState } from "reducers/uiReducers/editorContextReduce
import type { LibraryState } from "reducers/uiReducers/libraryReducer";
import type { AutoHeightLayoutTreeReduxState } from "reducers/entityReducers/autoHeightReducers/autoHeightLayoutTreeReducer";
import type { CanvasLevelsReduxState } from "reducers/entityReducers/autoHeightReducers/canvasLevelsReducer";
import type { LintErrors } from "reducers/lintingReducers/lintErrorsReducers";
import type { LintErrorsStore } from "reducers/lintingReducers/lintErrorsReducers";
import lintErrorReducer from "reducers/lintingReducers";
import type { AutoHeightUIState } from "reducers/uiReducers/autoHeightReducer";
import type { AnalyticsReduxState } from "reducers/uiReducers/analyticsReducer";
@ -159,7 +159,7 @@ export interface AppState {
triggers: TriggerValuesEvaluationState;
};
linting: {
errors: LintErrors;
errors: LintErrorsStore;
};
form: {
[key: string]: any;

View File

@ -8,6 +8,7 @@ import {
entityFns,
getPlatformFunctions,
} from "@appsmith/workers/Evaluation/fns";
import { klona } from "klona/full";
declare global {
/** All identifiers added to the worker global scope should also
* be included in the DEDICATED_WORKER_GLOBAL_SCOPE_IDENTIFIERS in
@ -33,21 +34,21 @@ export enum ExecutionType {
export const addDataTreeToContext = (args: {
EVAL_CONTEXT: EvalContext;
dataTree: Readonly<DataTree>;
skipEntityFunctions?: boolean;
removeEntityFunctions?: boolean;
isTriggerBased: boolean;
}) => {
const {
dataTree,
EVAL_CONTEXT,
isTriggerBased,
skipEntityFunctions = false,
removeEntityFunctions = false,
} = args;
const dataTreeEntries = Object.entries(dataTree);
const entityFunctionCollection: Record<string, Record<string, Function>> = {};
for (const [entityName, entity] of dataTreeEntries) {
EVAL_CONTEXT[entityName] = entity;
if (skipEntityFunctions || !isTriggerBased) continue;
if (!removeEntityFunctions && !isTriggerBased) continue;
for (const entityFn of entityFns) {
if (!entityFn.qualifier(entity)) continue;
const func = entityFn.fn(entity, entityName);
@ -56,6 +57,12 @@ export const addDataTreeToContext = (args: {
}
}
if (removeEntityFunctions)
return removeEntityFunctionsFromEvalContext(
entityFunctionCollection,
EVAL_CONTEXT,
);
// if eval is not trigger based i.e., sync eval then we skip adding entity and platform function to evalContext
if (!isTriggerBased) return;
@ -87,3 +94,18 @@ export const getAllAsyncFunctions = (dataTree: DataTree) => {
}
return asyncFunctionNameMap;
};
export const removeEntityFunctionsFromEvalContext = (
entityFunctionCollection: Record<string, Record<string, Function>>,
evalContext: EvalContext,
) => {
for (const [entityName, funcObj] of Object.entries(
entityFunctionCollection,
)) {
const entity = klona(evalContext[entityName]);
Object.keys(funcObj).forEach((entityFn) => {
delete entity[entityFn];
});
evalContext[entityName] = entity;
}
};

View File

@ -391,6 +391,15 @@ export function isJSAction(entity: DataTreeEntity): entity is JSActionEntity {
entity.ENTITY_TYPE === ENTITY_TYPE.JSACTION
);
}
export function isJSActionConfig(
entity: DataTreeEntityConfig,
): entity is JSActionEntityConfig {
return (
typeof entity === "object" &&
"ENTITY_TYPE" in entity &&
entity.ENTITY_TYPE === ENTITY_TYPE.JSACTION
);
}
export function isJSObject(entity: DataTreeEntity): entity is JSActionEntity {
return (
@ -402,6 +411,10 @@ export function isJSObject(entity: DataTreeEntity): entity is JSActionEntity {
);
}
export function isDataTreeEntity(entity: unknown) {
return !!entity && typeof entity === "object" && "ENTITY_TYPE" in entity;
}
// We need to remove functions from data tree to avoid any unexpected identifier while JSON parsing
// Check issue https://github.com/appsmithorg/appsmith/issues/719
export const removeFunctions = (value: any) => {

View File

@ -24,6 +24,7 @@ import { ReactComponent as CopyIcon } from "assets/icons/menu/copy-snippet.svg";
import copy from "copy-to-clipboard";
import type { EvaluationError } from "utils/DynamicBindingUtils";
import { PropertyEvaluationErrorCategory } from "utils/DynamicBindingUtils";
import * as Sentry from "@sentry/react";
import { Severity } from "@sentry/react";
import type { CodeEditorExpected } from "components/editorComponents/CodeEditor/index";
@ -33,6 +34,11 @@ import { useDispatch, useSelector } from "react-redux";
import { getEvaluatedPopupState } from "selectors/editorContextSelectors";
import type { AppState } from "@appsmith/reducers";
import { setEvalPopupState } from "actions/editorContextActions";
import { Link } from "react-router-dom";
import { showDebugger } from "actions/debuggerActions";
import { modText } from "utils/helpers";
import { getEntityNameAndPropertyPath } from "@appsmith/workers/Evaluation/evaluationUtils";
import { getJSFunctionNavigationUrl } from "selectors/navigationSelectors";
const modifiers: IPopoverSharedProps["modifiers"] = {
offset: {
@ -183,6 +189,24 @@ const StyledTitleName = styled.p`
cursor: pointer;
`;
const AsyncFunctionErrorLink = styled(Link)`
color: ${(props) => props.theme.colors.debugger.entityLink};
font-weight: 600;
font-size: 12px;
line-height: 14px;
cursor: pointer;
letter-spacing: 0.6px;
&:hover {
color: ${(props) => props.theme.colors.debugger.entityLink};
}
`;
const AsyncFunctionErrorView = styled.div`
display: flex;
margin-top: 12px;
justify-content: space-between;
`;
function CollapseToggle(props: { isOpen: boolean }) {
const { isOpen } = props;
return (
@ -462,16 +486,38 @@ function PopoverContent(props: PopoverContentProps) {
? popupContext.value
: true,
);
const { errors, expected, hasError, onMouseEnter, onMouseLeave, theme } =
props;
const { entityName } = getEntityNameAndPropertyPath(props.dataTreePath || "");
const JSFunctionInvocationError = errors.find(
({ kind }) =>
kind &&
kind.category ===
PropertyEvaluationErrorCategory.INVALID_JS_FUNCTION_INVOCATION_IN_DATA_FIELD &&
kind.rootcause,
);
const errorNavigationUrl = useSelector((state: AppState) =>
getJSFunctionNavigationUrl(
state,
entityName,
JSFunctionInvocationError?.kind?.rootcause,
),
);
const toggleExpectedDataType = () =>
setOpenExpectedDataType(!openExpectedDataType);
const toggleExpectedExample = () =>
setOpenExpectedExample(!openExpectedExample);
const { errors, expected, hasError, onMouseEnter, onMouseLeave, theme } =
props;
let error: EvaluationError | undefined;
if (hasError) {
error = errors[0];
}
const openDebugger = (
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
) => {
event.preventDefault();
dispatch(showDebugger());
};
useEffect(() => {
dispatch(
@ -508,13 +554,25 @@ function PopoverContent(props: PopoverContentProps) {
{/* errorMessage could be an empty string */}
{getErrorMessage(error.errorMessage)}
</span>
<EvaluatedValueDebugButton
entity={props.entity}
error={{
type: error.errorType,
message: error.errorMessage,
}}
/>
{errorNavigationUrl ? (
<AsyncFunctionErrorView>
<AsyncFunctionErrorLink onClick={(e) => openDebugger(e)} to="">
See Error ({modText()} D)
</AsyncFunctionErrorLink>
<AsyncFunctionErrorLink to={errorNavigationUrl}>
View Source
</AsyncFunctionErrorLink>
</AsyncFunctionErrorView>
) : (
<EvaluatedValueDebugButton
entity={props.entity}
error={{
type: error.errorType,
message: error.errorMessage,
}}
/>
)}
</ErrorText>
)}
{props.expected && props.expected.type !== UNDEFINED_VALIDATION && (

View File

@ -1263,6 +1263,7 @@ class CodeEditor extends Component<Props, State> {
text="/"
/>
)}
<EvaluatedValuePopup
dataTreePath={this.props.dataTreePath}
editorRef={this.codeEditorTarget}

View File

@ -1,6 +1,10 @@
import { Severity } from "entities/AppsmithConsole";
import type { LintError } from "utils/DynamicBindingUtils";
import { PropertyEvaluationErrorType } from "utils/DynamicBindingUtils";
import {
INVALID_JSOBJECT_START_STATEMENT,
INVALID_JSOBJECT_START_STATEMENT_ERROR_CODE,
} from "workers/Linting/constants";
import { CODE_EDITOR_START_POSITION } from "./constants";
import {
getKeyPositionInString,
@ -213,7 +217,23 @@ describe("getLintAnnotations()", () => {
}
`;
const errors: LintError[] = [];
const errors: LintError[] = [
{
errorType: PropertyEvaluationErrorType.LINT,
errorSegment: "",
originalBinding: value,
line: 0,
ch: 0,
code: INVALID_JSOBJECT_START_STATEMENT_ERROR_CODE,
variables: [],
raw: value,
errorMessage: {
name: "LintingError",
message: INVALID_JSOBJECT_START_STATEMENT,
},
severity: Severity.ERROR,
},
];
const res = getLintAnnotations(value, errors, { isJSObject: true });
expect(res).toEqual([

View File

@ -13,7 +13,7 @@ import {
CUSTOM_LINT_ERRORS,
IDENTIFIER_NOT_DEFINED_LINT_ERROR_CODE,
INVALID_JSOBJECT_START_STATEMENT,
JS_OBJECT_START_STATEMENT,
INVALID_JSOBJECT_START_STATEMENT_ERROR_CODE,
} from "workers/Linting/constants";
export const getIndexOfRegex = (
str: string,
@ -123,33 +123,32 @@ export const getLintAnnotations = (
const lintErrors = filterInvalidLintErrors(errors, contextData);
const lines = value.split("\n");
// The binding position of every valid JS Object is constant, so we need not
// waste time checking for position of binding.
// For JS Objects not starting with the expected "export default" statement, we return early
// with a "invalid start statement" lint error
if (
isJSObject &&
!isEmpty(lines) &&
!lines[0].startsWith(JS_OBJECT_START_STATEMENT)
) {
return [
{
from: CODE_EDITOR_START_POSITION,
to: getFirstNonEmptyPosition(lines),
message: INVALID_JSOBJECT_START_STATEMENT,
severity: Severity.ERROR,
},
];
}
lintErrors.forEach((error) => {
const { ch, errorMessage, line, originalBinding, severity, variables } =
error;
const {
ch,
code,
errorMessage,
line,
originalBinding,
severity,
variables,
} = error;
if (!originalBinding) {
return annotations;
}
if (code === INVALID_JSOBJECT_START_STATEMENT_ERROR_CODE) {
// The binding position of every valid JS Object is constant, so we need not
// waste time checking for position of binding.
// For JS Objects not starting with the expected "export default" statement, we return early
// with a "invalid start statement" lint error
return annotations.push({
from: CODE_EDITOR_START_POSITION,
to: getFirstNonEmptyPosition(lines),
message: INVALID_JSOBJECT_START_STATEMENT,
severity: Severity.ERROR,
});
}
let variableLength = 1;
// Find the variable with minimal length
if (variables) {

View File

@ -0,0 +1,14 @@
import type { EntityNavigationData } from "selectors/navigationSelectors";
export const addThisReference = (
navigationData: EntityNavigationData,
entityName?: string,
) => {
if (entityName && entityName in navigationData) {
return {
...navigationData,
this: navigationData[entityName],
};
}
return navigationData;
};

View File

@ -4,22 +4,21 @@ import { createImmerReducer } from "utils/ReducerUtils";
import type { SetLintErrorsAction } from "actions/lintingActions";
import { isEqual } from "lodash";
export interface LintErrors {
[entityName: string]: LintError[];
}
export type LintErrorsStore = Record<string, LintError[]>;
const initialState: LintErrors = {};
const initialState: LintErrorsStore = {};
export const lintErrorReducer = createImmerReducer(initialState, {
[ReduxActionTypes.FETCH_PAGE_INIT]: () => initialState,
[ReduxActionTypes.SET_LINT_ERRORS]: (
state: LintErrors,
state: LintErrorsStore,
action: SetLintErrorsAction,
) => {
const { errors } = action.payload;
for (const entityName of Object.keys(errors)) {
if (isEqual(state[entityName], errors[entityName])) continue;
state[entityName] = errors[entityName];
for (const entityPath of Object.keys(errors)) {
const entityPathLintErrors = errors[entityPath];
if (isEqual(entityPathLintErrors, state[entityPath])) continue;
state[entityPath] = entityPathLintErrors;
}
},
});

View File

@ -404,7 +404,7 @@ function* logDebuggerErrorAnalyticsSaga(
);
const pluginId = action?.pluginId || payload?.analytics?.pluginId || "";
const plugin: Plugin = yield select(getPlugin, pluginId);
const pluginName = plugin.name.replace(/ /g, "");
const pluginName = plugin?.name.replace(/ /g, "");
let propertyPath = `${pluginName}`;
if (payload.propertyPath) {

View File

@ -20,6 +20,7 @@ import { logJSFunctionExecution } from "@appsmith/sagas/JSFunctionExecutionSaga"
import { handleStoreOperations } from "./ActionExecution/StoreActionSaga";
import isEmpty from "lodash/isEmpty";
import { sortJSExecutionDataByCollectionId } from "workers/Evaluation/JSObject/utils";
import type { LintTreeSagaRequestData } from "workers/Linting/types";
export function* handleEvalWorkerRequestSaga(listenerChannel: Channel<any>) {
while (true) {
@ -31,12 +32,21 @@ export function* handleEvalWorkerRequestSaga(listenerChannel: Channel<any>) {
export function* lintTreeActionHandler(message: any) {
const { body } = message;
const { data } = body;
const {
asyncJSFunctionsInDataFields,
configTree,
jsPropertiesState,
pathsToLint: lintOrder,
unevalTree,
} = data as LintTreeSagaRequestData;
yield put({
type: ReduxActionTypes.LINT_TREE,
payload: {
pathsToLint: data.lintOrder,
unevalTree: data.unevalTree,
configTree: data.configTree,
pathsToLint: lintOrder,
unevalTree,
jsPropertiesState,
asyncJSFunctionsInDataFields,
configTree,
},
});
}

View File

@ -165,6 +165,7 @@ export function* evaluateTreeSaga(
requiresLinting: isEditMode && requiresLinting,
forceEvaluation,
metaWidgets,
appMode,
};
const workerResponse: EvalTreeResponseData = yield call(

View File

@ -14,6 +14,11 @@ import type {
import { LINT_WORKER_ACTIONS } from "workers/Linting/types";
import { logLatestLintPropertyErrors } from "./PostLintingSagas";
import { getAppsmithConfigs } from "@appsmith/configs";
import type { AppState } from "@appsmith/reducers";
import type { LintError } from "utils/DynamicBindingUtils";
import { get, set, union } from "lodash";
import type { LintErrorsStore } from "reducers/lintingReducers/lintErrorsReducers";
import type { TJSPropertiesState } from "workers/Evaluation/JSObject/jsPropertiesState";
const APPSMITH_CONFIGS = getAppsmithConfigs();
@ -35,8 +40,58 @@ function* updateLintGlobals(action: ReduxAction<TJSLibrary>) {
);
}
function* getValidOldJSCollectionLintErrors(
jsEntities: string[],
errors: LintErrorsStore,
jsObjectsState: TJSPropertiesState,
) {
const updatedJSCollectionLintErrors: LintErrorsStore = {};
for (const jsObjectName of jsEntities) {
const jsObjectBodyPath = `["${jsObjectName}.body"]`;
const oldJsBodyLintErrors: LintError[] = yield select((state: AppState) =>
get(state.linting.errors, jsObjectBodyPath, []),
);
const newJSBodyLintErrors = get(
errors,
jsObjectBodyPath,
[] as LintError[],
);
const newJSBodyLintErrorsOriginalPaths = newJSBodyLintErrors.reduce(
(paths, currentError) => {
if (currentError.originalPath)
return union(paths, [currentError.originalPath]);
return paths;
},
[] as string[],
);
const jsObjectState = get(jsObjectsState, jsObjectName, {});
const jsObjectProperties = Object.keys(jsObjectState);
const filteredOldJsObjectBodyLintErrors = oldJsBodyLintErrors.filter(
(lintError) =>
lintError.originalPath &&
lintError.originalPath in jsObjectProperties &&
!(lintError.originalPath in newJSBodyLintErrorsOriginalPaths),
);
const updatedLintErrors = [
...filteredOldJsObjectBodyLintErrors,
...newJSBodyLintErrors,
];
set(updatedJSCollectionLintErrors, jsObjectBodyPath, updatedLintErrors);
}
return updatedJSCollectionLintErrors;
}
export function* lintTreeSaga(action: ReduxAction<LintTreeSagaRequestData>) {
const { configTree, pathsToLint, unevalTree } = action.payload;
const {
asyncJSFunctionsInDataFields,
configTree,
jsPropertiesState,
pathsToLint,
unevalTree,
} = action.payload;
// only perform lint operations in edit mode
const appMode: APP_MODE = yield select(getAppMode);
if (appMode !== APP_MODE.EDIT) return;
@ -44,18 +99,32 @@ export function* lintTreeSaga(action: ReduxAction<LintTreeSagaRequestData>) {
const lintTreeRequestData: LintTreeRequest = {
pathsToLint,
unevalTree,
jsPropertiesState,
configTree,
cloudHosting: !!APPSMITH_CONFIGS.cloudHosting,
asyncJSFunctionsInDataFields,
};
const { errors }: LintTreeResponse = yield call(
const { errors, updatedJSEntities }: LintTreeResponse = yield call(
lintWorker.request,
LINT_WORKER_ACTIONS.LINT_TREE,
lintTreeRequestData,
);
yield put(setLintingErrors(errors));
yield call(logLatestLintPropertyErrors, { errors, dataTree: unevalTree });
const oldJSCollectionLintErrors: LintErrorsStore =
yield getValidOldJSCollectionLintErrors(
updatedJSEntities,
errors,
jsPropertiesState,
);
const updatedErrors = { ...errors, ...oldJSCollectionLintErrors };
yield put(setLintingErrors(updatedErrors));
yield call(logLatestLintPropertyErrors, {
errors,
dataTree: unevalTree,
});
}
export default function* lintTreeSagaWatcher() {

View File

@ -2,19 +2,19 @@ import { ENTITY_TYPE, Severity } from "entities/AppsmithConsole";
import LOG_TYPE from "entities/AppsmithConsole/logtype";
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import { isEmpty } from "lodash";
import type { LintErrors } from "reducers/lintingReducers/lintErrorsReducers";
import AppsmithConsole from "utils/AppsmithConsole";
import {
getEntityNameAndPropertyPath,
isJSAction,
} from "@appsmith/workers/Evaluation/evaluationUtils";
import type { LintErrorsStore } from "reducers/lintingReducers/lintErrorsReducers";
// We currently only log lint errors in JSObjects
export function* logLatestLintPropertyErrors({
dataTree,
errors,
}: {
errors: LintErrors;
errors: LintErrorsStore;
dataTree: DataTree;
}) {
const errorsToAdd = [];

View File

@ -1,11 +1,7 @@
import type { AppState } from "@appsmith/reducers";
import { get } from "lodash";
import type { LintErrors } from "reducers/lintingReducers/lintErrorsReducers";
import type { LintError } from "utils/DynamicBindingUtils";
export const getAllLintErrors = (state: AppState): LintErrors =>
state.linting.errors;
const emptyLint: LintError[] = [];
export const getEntityLintErrors = (state: AppState, path?: string) => {

View File

@ -19,7 +19,11 @@ import { createNavData } from "utils/NavigationSelector/common";
import { getWidgetChildrenNavData } from "utils/NavigationSelector/WidgetChildren";
import { getJsChildrenNavData } from "utils/NavigationSelector/JsChildren";
import { getAppsmithNavData } from "utils/NavigationSelector/AppsmithNavData";
import { isJSAction } from "ce/workers/Evaluation/evaluationUtils";
import {
getEntityNameAndPropertyPath,
isJSAction,
} from "@appsmith/workers/Evaluation/evaluationUtils";
import type { AppState } from "@appsmith/reducers";
export type NavigationData = {
name: string;
@ -124,3 +128,20 @@ export const getEntitiesForNavigation = createSelector(
return navigationData;
},
);
export const getJSFunctionNavigationUrl = createSelector(
[
(state: AppState, entityName: string) =>
getEntitiesForNavigation(state, entityName),
(_, __, jsFunctionFullName: string | undefined) => jsFunctionFullName,
],
(entitiesForNavigation, jsFunctionFullName) => {
if (!jsFunctionFullName) return undefined;
const { entityName: jsObjectName, propertyPath: jsFunctionName } =
getEntityNameAndPropertyPath(jsFunctionFullName);
const jsObjectNavigationData = entitiesForNavigation[jsObjectName];
const jsFuncNavigationData =
jsObjectNavigationData && jsObjectNavigationData.children[jsFunctionName];
return jsFuncNavigationData?.url;
},
);

View File

@ -353,6 +353,15 @@ export enum PropertyEvaluationErrorType {
PARSE = "PARSE",
LINT = "LINT",
}
export enum PropertyEvaluationErrorCategory {
INVALID_JS_FUNCTION_INVOCATION_IN_DATA_FIELD = "INVALID_JS_FUNCTION_INVOCATION_IN_DATA_FIELD",
}
export interface PropertyEvaluationErrorKind {
category: PropertyEvaluationErrorCategory;
rootcause: string;
}
export interface DataTreeError {
raw: string;
errorMessage: Error;
@ -364,6 +373,7 @@ export interface EvaluationError extends DataTreeError {
| PropertyEvaluationErrorType.PARSE
| PropertyEvaluationErrorType.VALIDATION;
originalBinding?: string;
kind?: PropertyEvaluationErrorKind;
}
export interface LintError extends DataTreeError {
@ -374,6 +384,7 @@ export interface LintError extends DataTreeError {
code: string;
line: number;
ch: number;
originalPath?: string;
}
export interface DataTreeEvaluationProps {

View File

@ -0,0 +1,247 @@
import {
getEntityNameAndPropertyPath,
isATriggerPath,
} from "@appsmith/workers/Evaluation/evaluationUtils";
import { APP_MODE } from "entities/App";
import type { ConfigTree, DataTree } from "entities/DataTree/dataTreeFactory";
import { difference, get, isString } from "lodash";
import type { DependencyMap } from "utils/DynamicBindingUtils";
import { getDynamicBindings } from "utils/DynamicBindingUtils";
import { isChildPropertyPath } from "utils/DynamicBindingUtils";
import {
isDataField,
isWidgetActionOrJsObject,
} from "workers/common/DataTreeEvaluator/utils";
import {
isAsyncJSFunction,
isJSFunction,
updateMap,
} from "workers/common/DependencyMap/utils";
export class AsyncJsFunctionInDataField {
private asyncFunctionsInDataFieldsMap: DependencyMap = {};
private isDisabled = true;
initialize(appMode: APP_MODE | undefined) {
this.isDisabled = !(appMode === APP_MODE.EDIT);
this.asyncFunctionsInDataFieldsMap = {};
}
update(
fullPath: string,
referencesInPath: string[],
unEvalDataTree: DataTree,
configTree: ConfigTree,
) {
if (this.isDisabled) return [];
const updatedAsyncJSFunctionsInMap = new Set<string>();
// Only datafields can cause updates
if (!isDataField(fullPath, configTree)) return [];
const asyncJSFunctionsInvokedInPath = getAsyncJSFunctionInvocationsInPath(
referencesInPath,
unEvalDataTree,
configTree,
fullPath,
);
for (const asyncJSFunc of asyncJSFunctionsInvokedInPath) {
updatedAsyncJSFunctionsInMap.add(asyncJSFunc);
updateMap(this.asyncFunctionsInDataFieldsMap, asyncJSFunc, [fullPath], {
deleteOnEmpty: true,
});
}
return Array.from(updatedAsyncJSFunctionsInMap);
}
handlePathDeletion(
deletedPath: string,
unevalTree: DataTree,
configTree: ConfigTree,
) {
if (this.isDisabled) return [];
const updatedAsyncJSFunctionsInMap = new Set<string>();
const { entityName, propertyPath } =
getEntityNameAndPropertyPath(deletedPath);
const entity = unevalTree[entityName];
const entityConfig = configTree[entityName];
if (
isWidgetActionOrJsObject(entity) ||
isATriggerPath(entityConfig, propertyPath)
)
return [];
Object.keys(this.asyncFunctionsInDataFieldsMap).forEach((asyncFuncName) => {
if (isChildPropertyPath(deletedPath, asyncFuncName)) {
this.deleteFunctionFromMap(asyncFuncName);
} else {
const toRemove: string[] = [];
this.asyncFunctionsInDataFieldsMap[asyncFuncName].forEach(
(dependantPath) => {
if (isChildPropertyPath(deletedPath, dependantPath)) {
updatedAsyncJSFunctionsInMap.add(asyncFuncName);
toRemove.push(dependantPath);
}
},
);
const newAsyncFunctiondependents = difference(
this.asyncFunctionsInDataFieldsMap[asyncFuncName],
toRemove,
);
updateMap(
this.asyncFunctionsInDataFieldsMap,
asyncFuncName,
newAsyncFunctiondependents,
{ replaceValue: true, deleteOnEmpty: true },
);
}
});
return Array.from(updatedAsyncJSFunctionsInMap);
}
handlePathEdit(
editedPath: string,
dependenciesInPath: string[],
unevalTree: DataTree,
inverseDependencyMap: DependencyMap,
configTree: ConfigTree,
) {
if (this.isDisabled) return [];
const updatedAsyncJSFunctionsInMap = new Set<string>();
if (isDataField(editedPath, configTree)) {
const asyncJSFunctionInvocationsInPath =
getAsyncJSFunctionInvocationsInPath(
dependenciesInPath,
unevalTree,
configTree,
editedPath,
);
asyncJSFunctionInvocationsInPath.forEach((funcName) => {
updatedAsyncJSFunctionsInMap.add(funcName);
updateMap(this.asyncFunctionsInDataFieldsMap, funcName, [editedPath], {
deleteOnEmpty: true,
});
});
Object.keys(this.asyncFunctionsInDataFieldsMap).forEach(
(asyncFuncName) => {
const toRemove: string[] = [];
this.asyncFunctionsInDataFieldsMap[asyncFuncName].forEach(
(dependantPath) => {
if (
editedPath === dependantPath &&
!asyncJSFunctionInvocationsInPath.includes(asyncFuncName)
) {
updatedAsyncJSFunctionsInMap.add(asyncFuncName);
toRemove.push(dependantPath);
}
},
);
const newAsyncFunctiondependents = difference(
this.asyncFunctionsInDataFieldsMap[asyncFuncName],
toRemove,
);
updateMap(
this.asyncFunctionsInDataFieldsMap,
asyncFuncName,
newAsyncFunctiondependents,
{ replaceValue: true, deleteOnEmpty: true },
);
},
);
} else if (isJSFunction(configTree, editedPath)) {
if (
!isAsyncJSFunction(configTree, editedPath) &&
Object.keys(this.asyncFunctionsInDataFieldsMap).includes(editedPath)
) {
updatedAsyncJSFunctionsInMap.add(editedPath);
delete this.asyncFunctionsInDataFieldsMap[editedPath];
} else if (isAsyncJSFunction(configTree, editedPath)) {
const boundFields = inverseDependencyMap[editedPath];
let boundDataFields: string[] = [];
if (boundFields) {
boundDataFields = boundFields.filter((path) =>
isDataField(path, configTree),
);
for (const dataFieldPath of boundDataFields) {
const asyncJSFunctionInvocationsInPath =
getAsyncJSFunctionInvocationsInPath(
[editedPath],
unevalTree,
configTree,
dataFieldPath,
);
if (asyncJSFunctionInvocationsInPath) {
updatedAsyncJSFunctionsInMap.add(editedPath);
updateMap(
this.asyncFunctionsInDataFieldsMap,
editedPath,
[dataFieldPath],
{ deleteOnEmpty: true },
);
}
}
}
}
}
return Array.from(updatedAsyncJSFunctionsInMap);
}
getMap() {
return this.asyncFunctionsInDataFieldsMap;
}
deleteFunctionFromMap(funcName: string) {
this.asyncFunctionsInDataFieldsMap[funcName] &&
delete this.asyncFunctionsInDataFieldsMap[funcName];
}
getAsyncFunctionBindingInDataField(fullPath: string): string | undefined {
let hasAsyncFunctionInvocation: string | undefined = undefined;
Object.keys(this.asyncFunctionsInDataFieldsMap).forEach((path) => {
if (this.asyncFunctionsInDataFieldsMap[path].includes(fullPath)) {
return (hasAsyncFunctionInvocation = path);
}
});
return hasAsyncFunctionInvocation;
}
}
function getAsyncJSFunctionInvocationsInPath(
dependencies: string[],
unEvalTree: DataTree,
configTree: ConfigTree,
fullPath: string,
) {
const invokedAsyncJSFunctions = new Set<string>();
const { entityName, propertyPath } = getEntityNameAndPropertyPath(fullPath);
const entity = unEvalTree[entityName];
const unevalPropValue = get(entity, propertyPath);
dependencies.forEach((dependant) => {
if (
isAsyncJSFunction(configTree, dependant) &&
isFunctionInvoked(dependant, unevalPropValue)
) {
invokedAsyncJSFunctions.add(dependant);
}
});
return Array.from(invokedAsyncJSFunctions);
}
function getFunctionInvocationRegex(funcName: string) {
return new RegExp(`${funcName}[.call | .apply]*\s*\\(.*?\\)`, "g");
}
export function isFunctionInvoked(
functionName: string,
unevalPropValue: unknown,
) {
if (!isString(unevalPropValue)) return false;
const { jsSnippets } = getDynamicBindings(unevalPropValue);
for (const jsSnippet of jsSnippets) {
if (!jsSnippet.includes(functionName)) continue;
const isInvoked = getFunctionInvocationRegex(functionName).test(jsSnippet);
if (isInvoked) return true;
}
return false;
}
export const asyncJsFunctionInDataFields = new AsyncJsFunctionInDataField();

View File

@ -2,7 +2,7 @@ import type { ConfigTree, DataTree } from "entities/DataTree/dataTreeFactory";
import { isEmpty, set } from "lodash";
import { EvalErrorTypes } from "utils/DynamicBindingUtils";
import type { JSUpdate, ParsedJSSubAction } from "utils/JSPaneUtils";
import { isTypeOfFunction, parseJSObjectWithAST } from "@shared/ast";
import { parseJSObject, isJSFunctionProperty } from "@shared/ast";
import type DataTreeEvaluator from "workers/common/DataTreeEvaluator";
import evaluateSync from "workers/Evaluation/evaluate";
import type { DataTreeDiff } from "@appsmith/workers/Evaluation/evaluationUtils";
@ -16,7 +16,9 @@ import {
updateJSCollectionInUnEvalTree,
} from "workers/Evaluation/JSObject/utils";
import { functionDeterminer } from "../functionDeterminer";
import { jsPropertiesState } from "./jsPropertiesState";
import type { JSActionEntity } from "entities/DataTree/types";
import { getFixedTimeDifference } from "workers/common/DataTreeEvaluator/utils";
/**
* Here we update our unEvalTree according to the change in JSObject's body
@ -81,94 +83,100 @@ export function saveResolvedFunctionsAndJSUpdates(
unEvalDataTree: DataTree,
entityName: string,
) {
jsPropertiesState.delete(entityName);
const correctFormat = regex.test(entity.body);
if (correctFormat) {
const body = entity.body.replace(/export default/g, "");
try {
delete dataTreeEvalRef.resolvedFunctions[`${entityName}`];
delete dataTreeEvalRef.currentJSCollectionState[`${entityName}`];
const parseStartTime = performance.now();
const parsedObject = parseJSObjectWithAST(body);
const { parsedObject, success } = parseJSObject(entity.body);
const parseEndTime = performance.now();
const JSObjectASTParseTime = parseEndTime - parseStartTime;
const JSObjectASTParseTime = getFixedTimeDifference(
parseEndTime,
parseStartTime,
);
dataTreeEvalRef.logs.push({
JSObjectName: entityName,
JSObjectASTParseTime,
});
const actions: any = [];
const variables: any = [];
if (!!parsedObject) {
parsedObject.forEach((parsedElement) => {
if (isTypeOfFunction(parsedElement.type)) {
try {
const { result } = evaluateSync(
parsedElement.value,
unEvalDataTree,
{},
false,
undefined,
undefined,
);
if (!!result) {
let params: Array<{ key: string; value: unknown }> = [];
if (success) {
if (!!parsedObject) {
jsPropertiesState.update(entityName, parsedObject);
parsedObject.forEach((parsedElement) => {
if (isJSFunctionProperty(parsedElement)) {
try {
const { result } = evaluateSync(
parsedElement.value,
unEvalDataTree,
{},
false,
undefined,
undefined,
);
if (!!result) {
let params: Array<{ key: string; value: unknown }> = [];
if (parsedElement.arguments) {
params = parsedElement.arguments.map(
({ defaultValue, paramName }) => ({
key: paramName,
value: defaultValue,
}),
if (parsedElement.arguments) {
params = parsedElement.arguments.map(
({ defaultValue, paramName }) => ({
key: paramName,
value: defaultValue,
}),
);
}
const functionString = parsedElement.value;
set(
dataTreeEvalRef.resolvedFunctions,
`${entityName}.${parsedElement.key}`,
result,
);
set(
dataTreeEvalRef.currentJSCollectionState,
`${entityName}.${parsedElement.key}`,
functionString,
);
actions.push({
name: parsedElement.key,
body: functionString,
arguments: params,
parsedFunction: result,
isAsync: false,
});
}
const functionString = parsedElement.value;
set(
dataTreeEvalRef.resolvedFunctions,
`${entityName}.${parsedElement.key}`,
result,
);
set(
dataTreeEvalRef.currentJSCollectionState,
`${entityName}.${parsedElement.key}`,
functionString,
);
actions.push({
name: parsedElement.key,
body: functionString,
arguments: params,
parsedFunction: result,
isAsync: false,
});
} catch {
// in case we need to handle error state
}
} catch {
// in case we need to handle error state
} else if (parsedElement.type !== "literal") {
variables.push({
name: parsedElement.key,
value: parsedElement.value,
});
set(
dataTreeEvalRef.currentJSCollectionState,
`${entityName}.${parsedElement.key}`,
parsedElement.value,
);
}
} else if (parsedElement.type !== "literal") {
variables.push({
name: parsedElement.key,
value: parsedElement.value,
});
set(
dataTreeEvalRef.currentJSCollectionState,
`${entityName}.${parsedElement.key}`,
parsedElement.value,
);
}
});
const parsedBody = {
body: entity.body,
actions: actions,
variables,
};
set(jsUpdates, `${entityName}`, {
parsedBody,
id: entity.actionId,
});
} else {
set(jsUpdates, `${entityName}`, {
parsedBody: undefined,
id: entity.actionId,
});
});
const parsedBody = {
body: entity.body,
actions: actions,
variables,
};
set(jsUpdates, `${entityName}`, {
parsedBody,
id: entity.actionId,
});
} else {
set(jsUpdates, `${entityName}`, {
parsedBody: undefined,
id: entity.actionId,
});
}
}
} catch (e) {
//if we need to push error as popup in case
@ -194,6 +202,7 @@ export function parseJSActions(
differences?: DataTreeDiff[],
) {
let jsUpdates: Record<string, JSUpdate> = {};
jsPropertiesState.startUpdate();
if (!!differences && !!oldUnEvalTree) {
differences.forEach((diff) => {
const { entityName, propertyPath } = getEntityNameAndPropertyPath(
@ -249,7 +258,7 @@ export function parseJSActions(
);
});
}
jsPropertiesState.stopUpdate();
functionDeterminer.setupEval(
unEvalDataTree,
dataTreeEvalRef.resolvedFunctions,

View File

@ -0,0 +1,79 @@
import type { JSPropertyPosition, TParsedJSProperty } from "@shared/ast";
import { isJSFunctionProperty } from "@shared/ast";
import { diff } from "deep-diff";
import { klona } from "klona/full";
import { set, union } from "lodash";
class JsPropertiesState {
private jsPropertiesState: TJSPropertiesState = {};
private oldJsPropertiesState: TJSPropertiesState = {};
private updatedProperties: string[] = [];
startUpdate() {
this.oldJsPropertiesState = klona(this.jsPropertiesState);
}
delete(jsObjectName: string) {
delete this.jsPropertiesState[`${jsObjectName}`];
}
update(jsObjectName: string, properties: TParsedJSProperty[]) {
for (const jsObjectProperty of properties) {
const { key, position, rawContent, type } = jsObjectProperty;
if (isJSFunctionProperty(jsObjectProperty)) {
set(
this.jsPropertiesState,
`[${jsObjectName}.${jsObjectProperty.key}]`,
{
position: position,
value: rawContent,
isMarkedAsync: jsObjectProperty.isMarkedAsync,
},
);
} else if (type !== "literal") {
set(this.jsPropertiesState, `[${jsObjectName}.${key}]`, {
position: position,
value: rawContent,
});
}
}
}
stopUpdate() {
const difference = diff(this.oldJsPropertiesState, this.jsPropertiesState);
let updatedJSProperties: string[] = [];
if (difference) {
updatedJSProperties = difference.reduce(
(updatedProperties, currentDiff) => {
if (!currentDiff.path) return updatedProperties;
const updatedProperty = currentDiff.path.slice(0, 2).join(".");
return union(updatedProperties, [updatedProperty]);
},
[] as string[],
);
}
this.updatedProperties = updatedJSProperties;
}
getMap() {
return this.jsPropertiesState;
}
getUpdatedJSProperties() {
return this.updatedProperties;
}
}
export const jsPropertiesState = new JsPropertiesState();
export interface TBasePropertyState {
value: string;
position: JSPropertyPosition;
}
export interface TJSFunctionPropertyState extends TBasePropertyState {
isMarkedAsync: boolean;
}
export type TJSpropertyState = TBasePropertyState | TJSFunctionPropertyState;
export type TJSPropertiesState = Record<
string,
Record<string, TJSpropertyState>
>;

File diff suppressed because it is too large Load Diff

View File

@ -216,7 +216,6 @@ export const removeFunctionsAndVariableJSCollection = (
unset(modifiedDataTree[entityName], varName);
}
//remove functions
const reactivePaths = entity.reactivePaths;
const meta = entity.meta;

View File

@ -124,46 +124,46 @@ describe("Test error modifier", () => {
errorModifier.updateAsyncFunctions(dataTree);
});
it("TypeError for defined Api in sync field ", () => {
it("TypeError for defined Api in data field ", () => {
const error = new Error();
error.name = "TypeError";
error.message = "Api2.run is not a function";
const result = errorModifier.run(error);
const { errorMessage: result } = errorModifier.run(error);
expect(result).toEqual({
name: "ValidationError",
message:
"Found a reference to Api2.run() during evaluation. Sync fields cannot execute framework actions. Please remove any direct/indirect references to Api2.run() and try again.",
"Found a reference to Api2.run() during evaluation. Data fields cannot execute framework actions. Please remove any direct/indirect references to Api2.run() and try again.",
});
});
it("TypeError for undefined Api in sync field ", () => {
it("TypeError for undefined Api in data field ", () => {
const error = new Error();
error.name = "TypeError";
error.message = "Api1.run is not a function";
const result = errorModifier.run(error);
const { errorMessage: result } = errorModifier.run(error);
expect(result).toEqual({
name: "TypeError",
message: "Api1.run is not a function",
});
});
it("ReferenceError for platform function in sync field", () => {
it("ReferenceError for platform function in data field", () => {
const error = new Error();
error.name = "ReferenceError";
error.message = "storeValue is not defined";
const result = errorModifier.run(error);
const { errorMessage: result } = errorModifier.run(error);
expect(result).toEqual({
name: "ValidationError",
message:
"Found a reference to storeValue() during evaluation. Sync fields cannot execute framework actions. Please remove any direct/indirect references to storeValue() and try again.",
"Found a reference to storeValue() during evaluation. Data fields cannot execute framework actions. Please remove any direct/indirect references to storeValue() and try again.",
});
});
it("ReferenceError for undefined function in sync field", () => {
it("ReferenceError for undefined function in data field", () => {
const error = new Error();
error.name = "ReferenceError";
error.message = "storeValue2 is not defined";
const result = errorModifier.run(error);
const { errorMessage: result } = errorModifier.run(error);
expect(result).toEqual({
name: error.name,
message: error.message,

View File

@ -76,6 +76,7 @@ describe("evaluateSync", () => {
message: "wrongJS is not defined",
},
errorType: "PARSE",
kind: undefined,
raw: `
function $$closedFn () {
const $$result = wrongJS
@ -98,6 +99,7 @@ describe("evaluateSync", () => {
message: "{}.map is not a function",
},
errorType: "PARSE",
kind: undefined,
raw: `
function $$closedFn () {
const $$result = {}.map()
@ -128,6 +130,7 @@ describe("evaluateSync", () => {
message: "setImmediate is not defined",
},
errorType: "PARSE",
kind: undefined,
raw: `
function $$closedFn () {
const $$result = setImmediate(() => {}, 100)

View File

@ -1,9 +1,12 @@
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import { getAllAsyncFunctions } from "@appsmith/workers/Evaluation/Actions";
import type { EvaluationError } from "utils/DynamicBindingUtils";
import { PropertyEvaluationErrorCategory } from "utils/DynamicBindingUtils";
const FOUND_ASYNC_IN_SYNC_EVAL_MESSAGE =
"Found an action invocation during evaluation. Data fields cannot execute actions.";
const UNDEFINED_ACTION_IN_SYNC_EVAL_ERROR =
"Found a reference to {{actionName}} during evaluation. Sync fields cannot execute framework actions. Please remove any direct/indirect references to {{actionName}} and try again.";
"Found a reference to {{actionName}} during evaluation. Data fields cannot execute framework actions. Please remove any direct/indirect references to {{actionName}} and try again.";
class ErrorModifier {
private errorNamesToScan = ["ReferenceError", "TypeError"];
// Note all regex below groups the async function name
@ -14,10 +17,23 @@ class ErrorModifier {
this.asyncFunctionsNameMap = getAllAsyncFunctions(dataTree);
}
run(error: Error) {
run(error: Error): {
errorMessage: ReturnType<typeof getErrorMessage>;
errorCategory?: PropertyEvaluationErrorCategory;
} {
const errorMessage = getErrorMessage(error);
if (
error instanceof FoundPromiseInSyncEvalError ||
error instanceof ActionCalledInSyncFieldError
) {
return {
errorMessage,
errorCategory:
PropertyEvaluationErrorCategory.INVALID_JS_FUNCTION_INVOCATION_IN_DATA_FIELD,
};
}
if (!this.errorNamesToScan.includes(error.name)) return errorMessage;
if (!this.errorNamesToScan.includes(error.name)) return { errorMessage };
for (const asyncFunctionFullPath of Object.keys(
this.asyncFunctionsNameMap,
@ -25,27 +41,49 @@ class ErrorModifier {
const functionNameWithWhiteSpace = " " + asyncFunctionFullPath + " ";
if (getErrorMessageWithType(error).match(functionNameWithWhiteSpace)) {
return {
name: "ValidationError",
message: UNDEFINED_ACTION_IN_SYNC_EVAL_ERROR.replaceAll(
"{{actionName}}",
asyncFunctionFullPath + "()",
),
errorMessage: {
name: "ValidationError",
message: UNDEFINED_ACTION_IN_SYNC_EVAL_ERROR.replaceAll(
"{{actionName}}",
asyncFunctionFullPath + "()",
),
},
errorCategory:
PropertyEvaluationErrorCategory.INVALID_JS_FUNCTION_INVOCATION_IN_DATA_FIELD,
};
}
}
return errorMessage;
return { errorMessage };
}
setAsyncInvocationErrorsRootcause(
errors: EvaluationError[],
asyncFunc: string,
) {
return errors.map((error) => {
if (isAsyncFunctionCalledInSyncFieldError(error)) {
error.errorMessage.message = FOUND_ASYNC_IN_SYNC_EVAL_MESSAGE;
error.kind = {
category:
PropertyEvaluationErrorCategory.INVALID_JS_FUNCTION_INVOCATION_IN_DATA_FIELD,
rootcause: asyncFunc,
};
}
return error;
});
}
}
export const errorModifier = new ErrorModifier();
const FOUND_PROMISE_IN_SYNC_EVAL_MESSAGE =
"Found a Promise() during evaluation. Data fields cannot execute asynchronous code.";
export class FoundPromiseInSyncEvalError extends Error {
constructor() {
super();
this.name = "";
this.message =
"Found a Promise() during evaluation. Sync fields cannot execute asynchronous code.";
this.message = FOUND_PROMISE_IN_SYNC_EVAL_MESSAGE;
}
}
@ -54,7 +92,7 @@ export class ActionCalledInSyncFieldError extends Error {
super(actionName);
if (!actionName) {
this.message = "Async function called in a sync field";
this.message = "Async function called in a data field";
return;
}
@ -81,3 +119,10 @@ export const getErrorMessage = (error: Error) => {
export const getErrorMessageWithType = (error: Error) => {
return error.name ? `${error.name}: ${error.message}` : error.message;
};
function isAsyncFunctionCalledInSyncFieldError(error: EvaluationError) {
return (
error.kind?.category ===
PropertyEvaluationErrorCategory.INVALID_JS_FUNCTION_INVOCATION_IN_DATA_FIELD
);
}

View File

@ -23,6 +23,7 @@ export enum EvaluationScriptType {
ANONYMOUS_FUNCTION = "ANONYMOUS_FUNCTION",
ASYNC_ANONYMOUS_FUNCTION = "ASYNC_ANONYMOUS_FUNCTION",
TRIGGERS = "TRIGGERS",
OBJECT_PROPERTY = "OBJECT_PROPERTY",
}
export const ScriptTemplate = "<<string>>";
@ -58,6 +59,13 @@ export const EvaluationScripts: Record<EvaluationScriptType, string> = {
}
$$closedFn.call(THIS_CONTEXT)
`,
[EvaluationScriptType.OBJECT_PROPERTY]: `
function $$closedFn () {
const $$result = {${ScriptTemplate}}
return $$result
}
$$closedFn.call(THIS_CONTEXT)
`,
};
const topLevelWorkerAPIs = Object.keys(self).reduce((acc, key: string) => {
@ -120,8 +128,11 @@ export interface createEvaluationContextArgs {
context?: EvaluateContext;
isTriggerBased: boolean;
evalArguments?: Array<unknown>;
// Whether not to add functions like "run", "clear" to entity in global data
skipEntityFunctions?: boolean;
/*
Whether to remove functions like "run", "clear" from entities in global context
use case => To show lint warning when Api.run is used in a function bound to a data field (Eg. Button.text)
*/
removeEntityFunctions?: boolean;
}
/**
* This method created an object with dataTree and appsmith's framework actions that needs to be added to worker global scope for the JS code evaluation to then consume it.
@ -135,8 +146,8 @@ export const createEvaluationContext = (args: createEvaluationContextArgs) => {
dataTree,
evalArguments,
isTriggerBased,
removeEntityFunctions,
resolvedFunctions,
skipEntityFunctions,
} = args;
const EVAL_CONTEXT: EvalContext = {};
@ -152,7 +163,7 @@ export const createEvaluationContext = (args: createEvaluationContextArgs) => {
addDataTreeToContext({
EVAL_CONTEXT,
dataTree,
skipEntityFunctions: !!skipEntityFunctions,
removeEntityFunctions: !!removeEntityFunctions,
isTriggerBased,
});
@ -279,18 +290,23 @@ export default function evaluateSync(
result = indirectEval(script);
if (result instanceof Promise) {
/**
* If a promise is returned in sync field then show the error to help understand sync field doesn't await to resolve promise.
* NOTE: Awaiting for promise will make sync field evaluation slower.
* If a promise is returned in data field then show the error to help understand data field doesn't await to resolve promise.
* NOTE: Awaiting for promise will make data field evaluation slower.
*/
throw new FoundPromiseInSyncEvalError();
}
} catch (error) {
const { errorCategory, errorMessage } = errorModifier.run(error as Error);
errors.push({
errorMessage: errorModifier.run(error as Error),
errorMessage,
severity: Severity.ERROR,
raw: script,
errorType: PropertyEvaluationErrorType.PARSE,
originalBinding: userScript,
kind: errorCategory && {
category: errorCategory,
rootcause: "",
},
});
} finally {
for (const entityName in evalContext) {

View File

@ -1,7 +1,7 @@
import type { ConfigTree, DataTree } from "entities/DataTree/dataTreeFactory";
import type ReplayEntity from "entities/Replay";
import ReplayCanvas from "entities/Replay/ReplayEntity/ReplayCanvas";
import { isEmpty } from "lodash";
import { isEmpty, union } from "lodash";
import type { DependencyMap, EvalError } from "utils/DynamicBindingUtils";
import { EvalErrorTypes } from "utils/DynamicBindingUtils";
import type { JSUpdate } from "utils/JSPaneUtils";
@ -20,6 +20,8 @@ import type {
EvalWorkerSyncRequest,
} from "../types";
import { clearAllIntervals } from "../fns/overrides/interval";
import { jsPropertiesState } from "../JSObject/jsPropertiesState";
import { asyncJsFunctionInDataFields } from "../JSObject/asyncJSFunctionBoundToDataField";
export let replayMap: Record<string, ReplayEntity<any>>;
export let dataTreeEvaluator: DataTreeEvaluator | undefined;
export const CANVAS = "canvas";
@ -44,6 +46,7 @@ export default function (request: EvalWorkerSyncRequest) {
const {
allActionValidationConfig,
appMode,
forceEvaluation,
metaWidgets,
requiresLinting,
@ -59,6 +62,7 @@ export default function (request: EvalWorkerSyncRequest) {
try {
if (!dataTreeEvaluator) {
isCreateFirstTree = true;
asyncJsFunctionInDataFields.initialize(appMode);
replayMap = replayMap || {};
replayMap[CANVAS] = new ReplayCanvas({ widgets, theme });
dataTreeEvaluator = new DataTreeEvaluator(
@ -71,17 +75,25 @@ export default function (request: EvalWorkerSyncRequest) {
configTree,
);
evalOrder = setupFirstTreeResponse.evalOrder;
lintOrder = setupFirstTreeResponse.lintOrder;
lintOrder = union(
setupFirstTreeResponse.lintOrder,
jsPropertiesState.getUpdatedJSProperties(),
);
jsUpdates = setupFirstTreeResponse.jsUpdates;
initiateLinting(
initiateLinting({
lintOrder,
makeEntityConfigsAsObjProperties(dataTreeEvaluator.oldUnEvalTree, {
sanitizeDataTree: false,
}),
unevalTree: makeEntityConfigsAsObjProperties(
dataTreeEvaluator.oldUnEvalTree,
{
sanitizeDataTree: false,
},
),
requiresLinting,
dataTreeEvaluator.oldConfigTree,
);
jsPropertiesState: jsPropertiesState.getMap(),
asyncJSFunctionsInDataFields: asyncJsFunctionInDataFields.getMap(),
configTree: dataTreeEvaluator.oldConfigTree,
});
const dataTreeResponse = dataTreeEvaluator.evalAndValidateFirstTree();
dataTree = makeEntityConfigsAsObjProperties(dataTreeResponse.evalTree, {
@ -113,17 +125,25 @@ export default function (request: EvalWorkerSyncRequest) {
);
isCreateFirstTree = true;
evalOrder = setupFirstTreeResponse.evalOrder;
lintOrder = setupFirstTreeResponse.lintOrder;
lintOrder = union(
setupFirstTreeResponse.lintOrder,
jsPropertiesState.getUpdatedJSProperties(),
);
jsUpdates = setupFirstTreeResponse.jsUpdates;
initiateLinting(
initiateLinting({
lintOrder,
makeEntityConfigsAsObjProperties(dataTreeEvaluator.oldUnEvalTree, {
sanitizeDataTree: false,
}),
unevalTree: makeEntityConfigsAsObjProperties(
dataTreeEvaluator.oldUnEvalTree,
{
sanitizeDataTree: false,
},
),
requiresLinting,
dataTreeEvaluator.oldConfigTree,
);
jsPropertiesState: jsPropertiesState.getMap(),
asyncJSFunctionsInDataFields: asyncJsFunctionInDataFields.getMap(),
configTree: dataTreeEvaluator.oldConfigTree,
});
const dataTreeResponse = dataTreeEvaluator.evalAndValidateFirstTree();
dataTree = makeEntityConfigsAsObjProperties(dataTreeResponse.evalTree, {
@ -146,20 +166,28 @@ export default function (request: EvalWorkerSyncRequest) {
);
evalOrder = setupUpdateTreeResponse.evalOrder;
lintOrder = setupUpdateTreeResponse.lintOrder;
lintOrder = union(
setupUpdateTreeResponse.lintOrder,
jsPropertiesState.getUpdatedJSProperties(),
);
jsUpdates = setupUpdateTreeResponse.jsUpdates;
unEvalUpdates = setupUpdateTreeResponse.unEvalUpdates;
pathsToClearErrorsFor = setupUpdateTreeResponse.pathsToClearErrorsFor;
isNewWidgetAdded = setupUpdateTreeResponse.isNewWidgetAdded;
initiateLinting(
initiateLinting({
lintOrder,
makeEntityConfigsAsObjProperties(dataTreeEvaluator.oldUnEvalTree, {
sanitizeDataTree: false,
}),
unevalTree: makeEntityConfigsAsObjProperties(
dataTreeEvaluator.oldUnEvalTree,
{
sanitizeDataTree: false,
},
),
requiresLinting,
dataTreeEvaluator.oldConfigTree,
);
jsPropertiesState: jsPropertiesState.getMap(),
asyncJSFunctionsInDataFields: asyncJsFunctionInDataFields.getMap(),
configTree: dataTreeEvaluator.oldConfigTree,
});
nonDynamicFieldValidationOrder =
setupUpdateTreeResponse.nonDynamicFieldValidationOrder;

View File

@ -18,6 +18,7 @@ import type { WidgetTypeConfigMap } from "utils/WidgetFactory";
import type { EvalMetaUpdates } from "@appsmith/workers/common/DataTreeEvaluator/types";
import type { WorkerRequest } from "@appsmith/workers/common/types";
import type { DataTreeDiff } from "@appsmith/workers/Evaluation/evaluationUtils";
import type { APP_MODE } from "entities/App";
export type EvalWorkerSyncRequest = WorkerRequest<any, EVAL_WORKER_SYNC_ACTION>;
export type EvalWorkerASyncRequest = WorkerRequest<
@ -38,6 +39,7 @@ export interface EvalTreeRequestData {
requiresLinting: boolean;
forceEvaluation: boolean;
metaWidgets: MetaWidgetsReduxState;
appMode: APP_MODE | undefined;
}
export interface EvalTreeResponseData {

View File

@ -1,5 +1,6 @@
import { ECMA_VERSION } from "@shared/ast";
import type { LintOptions } from "jshint";
import { isEntityFunction } from "./utils";
export const lintOptions = (globalData: Record<string, boolean>) =>
({
@ -29,6 +30,8 @@ export const lintOptions = (globalData: Record<string, boolean>) =>
} as LintOptions);
export const JS_OBJECT_START_STATEMENT = "export default";
export const INVALID_JSOBJECT_START_STATEMENT = `JSObject must start with '${JS_OBJECT_START_STATEMENT}'`;
export const INVALID_JSOBJECT_START_STATEMENT_ERROR_CODE =
"INVALID_JSOBJECT_START_STATEMENT_ERROR_CODE";
// https://github.com/jshint/jshint/blob/d3d84ae1695359aef077ddb143f4be98001343b4/src/messages.js#L204
export const IDENTIFIER_NOT_DEFINED_LINT_ERROR_CODE = "W117";
@ -37,10 +40,14 @@ export const IDENTIFIER_NOT_DEFINED_LINT_ERROR_CODE = "W117";
export const WARNING_LINT_ERRORS = {
W098: "'{a}' is defined but never used.",
W014: "Misleading line break before '{a}'; readers may interpret this as an expression boundary.",
ASYNC_FUNCTION_BOUND_TO_SYNC_FIELD:
"Cannot execute async code on functions bound to data fields",
};
export function asyncActionInSyncFieldLintMessage(actionName: string) {
return `Async framework action "${actionName}" cannot be executed in a function that is bound to a sync field.`;
export function asyncActionInSyncFieldLintMessage(isJsObject = false) {
return isJsObject
? `Cannot execute async code on functions bound to data fields`
: `Data fields cannot execute async code`;
}
/** These errors should be overlooked
@ -54,7 +61,9 @@ export const SUPPORTED_WEB_APIS = {
};
export enum CustomLintErrorCode {
INVALID_ENTITY_PROPERTY = "INVALID_ENTITY_PROPERTY",
ASYNC_FUNCTION_BOUND_TO_SYNC_FIELD = "ASYNC_FUNCTION_BOUND_TO_SYNC_FIELD",
}
export const CUSTOM_LINT_ERRORS: Record<
CustomLintErrorCode,
(...args: any[]) => string
@ -62,5 +71,26 @@ export const CUSTOM_LINT_ERRORS: Record<
[CustomLintErrorCode.INVALID_ENTITY_PROPERTY]: (
entityName: string,
propertyName: string,
) => `"${propertyName}" doesn't exist in ${entityName}`,
entity: unknown,
isJsObject: boolean,
) =>
isEntityFunction(entity, propertyName)
? asyncActionInSyncFieldLintMessage(isJsObject)
: `"${propertyName}" doesn't exist in ${entityName}`,
[CustomLintErrorCode.ASYNC_FUNCTION_BOUND_TO_SYNC_FIELD]: (
dataFieldBindings: string[],
fullName: string,
isMarkedAsync: boolean,
) => {
const hasMultipleBindings = dataFieldBindings.length > 1;
const bindings = dataFieldBindings.join(" , ");
return isMarkedAsync
? `Cannot bind async functions to data fields. Convert this to a sync function or remove references to "${fullName}" on the following data ${
hasMultipleBindings ? "fields" : "field"
}: ${bindings}`
: `Functions bound to data fields cannot execute async code. Remove async statements highlighted below or remove references to "${fullName}" on the following data ${
hasMultipleBindings ? "fields" : "field"
}: ${bindings}`;
},
};

View File

@ -0,0 +1,47 @@
import type { DataTree } from "entities/DataTree/dataTreeFactory";
import { isEmpty } from "lodash";
import type { EvalContext } from "workers/Evaluation/evaluate";
import { getEvaluationContext } from "./utils";
class GlobalData {
globalDataWithFunctions: EvalContext = {};
globalDataWithoutFunctions: EvalContext = {};
unevalTree: DataTree = {};
cloudHosting = false;
initialize(unevalTree: DataTree, cloudHosting: boolean) {
this.globalDataWithFunctions = {};
this.globalDataWithoutFunctions = {};
this.unevalTree = unevalTree;
this.cloudHosting = cloudHosting;
}
getGlobalData(withFunctions: boolean) {
// Our goal is to create global data (with or without functions) only once during a linting cycle
if (withFunctions) {
if (isEmpty(this.globalDataWithFunctions)) {
this.globalDataWithFunctions = getEvaluationContext(
this.unevalTree,
this.cloudHosting,
{
withFunctions: true,
},
);
}
return this.globalDataWithFunctions;
} else {
if (isEmpty(this.globalDataWithoutFunctions)) {
this.globalDataWithoutFunctions = getEvaluationContext(
this.unevalTree,
this.cloudHosting,
{
withFunctions: false,
},
);
}
return this.globalDataWithoutFunctions;
}
}
}
export const globalData = new GlobalData();

View File

@ -1,126 +1,110 @@
import { getEntityNameAndPropertyPath } from "@appsmith/workers/Evaluation/evaluationUtils";
import { get, isEmpty, set } from "lodash";
import type { LintErrorsStore } from "reducers/lintingReducers/lintErrorsReducers";
import type { LintError } from "utils/DynamicBindingUtils";
import { globalData } from "./globalData";
import type {
getlintErrorsFromTreeProps,
getlintErrorsFromTreeResponse,
} from "./types";
import {
getEntityNameAndPropertyPath,
isATriggerPath,
isJSAction,
} from "ce/workers/Evaluation/evaluationUtils";
import type { ConfigTree, DataTree } from "entities/DataTree/dataTreeFactory";
import { get, set } from "lodash";
import type { LintErrors } from "reducers/lintingReducers/lintErrorsReducers";
import { createEvaluationContext } from "workers/Evaluation/evaluate";
import { getActionTriggerFunctionNames } from "workers/Evaluation/fns";
import { lintBindingPath, lintTriggerPath, pathRequiresLinting } from "./utils";
lintBindingPath,
lintJSObjectBody,
lintJSObjectProperty,
lintTriggerPath,
sortLintingPathsByType,
} from "./utils";
export function getlintErrorsFromTree(
pathsToLint: string[],
unEvalTree: DataTree,
configTree: ConfigTree,
cloudHosting: boolean,
): LintErrors {
const lintTreeErrors: LintErrors = {};
const evalContext = createEvaluationContext({
dataTree: unEvalTree,
resolvedFunctions: {},
isTriggerBased: false,
skipEntityFunctions: true,
});
const platformFnNamesMap = Object.values(
getActionTriggerFunctionNames(cloudHosting),
).reduce(
(acc, name) => ({ ...acc, [name]: true }),
{} as { [x: string]: boolean },
export function getlintErrorsFromTree({
asyncJSFunctionsInDataFields,
cloudHosting,
configTree,
jsPropertiesState,
pathsToLint,
unEvalTree,
}: getlintErrorsFromTreeProps): getlintErrorsFromTreeResponse {
const lintTreeErrors: LintErrorsStore = {};
const updatedJSEntities = new Set<string>();
globalData.initialize(unEvalTree, cloudHosting);
const { bindingPaths, jsObjectPaths, triggerPaths } = sortLintingPathsByType(
pathsToLint,
unEvalTree,
configTree,
);
Object.assign(evalContext, platformFnNamesMap);
const evalContextWithoutFunctions = createEvaluationContext({
dataTree: unEvalTree,
resolvedFunctions: {},
isTriggerBased: true,
skipEntityFunctions: true,
});
// trigger paths
const triggerPaths = new Set<string>();
// Certain paths, like JS Object's body are binding paths where appsmith functions are needed in the global data
const bindingPathsRequiringFunctions = new Set<string>();
pathsToLint.forEach((fullPropertyPath) => {
const { entityName, propertyPath } =
getEntityNameAndPropertyPath(fullPropertyPath);
// Lint binding paths
bindingPaths.forEach((bindingPath) => {
const { entityName } = getEntityNameAndPropertyPath(bindingPath);
const entity = unEvalTree[entityName];
const entityConfig = configTree[entityName];
const unEvalPropertyValue = get(
unEvalTree,
fullPropertyPath,
bindingPath,
) as unknown as string;
// remove all lint errors from path
set(lintTreeErrors, `["${fullPropertyPath}"]`, []);
// We are only interested in paths that require linting
if (
!pathRequiresLinting(unEvalTree, entity, fullPropertyPath, entityConfig)
)
return;
if (isATriggerPath(entityConfig, propertyPath))
return triggerPaths.add(fullPropertyPath);
if (isJSAction(entity))
return bindingPathsRequiringFunctions.add(`${entityName}.body`);
const lintErrors = lintBindingPath({
entity,
fullPropertyPath,
globalData: evalContextWithoutFunctions,
dynamicBinding: unEvalPropertyValue,
entity,
fullPropertyPath: bindingPath,
globalData: globalData.getGlobalData(false),
});
set(lintTreeErrors, `["${fullPropertyPath}"]`, lintErrors);
set(lintTreeErrors, `["${bindingPath}"]`, lintErrors);
});
if (triggerPaths.size || bindingPathsRequiringFunctions.size) {
// we only create GLOBAL_DATA_WITH_FUNCTIONS if there are paths requiring it
// In trigger based fields, functions such as showAlert, storeValue, etc need to be added to the global data
// Lint TriggerPaths
triggerPaths.forEach((triggerPath) => {
const { entityName } = getEntityNameAndPropertyPath(triggerPath);
const entity = unEvalTree[entityName];
const unEvalPropertyValue = get(
unEvalTree,
triggerPath,
) as unknown as string;
// remove all lint errors from path
set(lintTreeErrors, `["${triggerPath}"]`, []);
const lintErrors = lintTriggerPath({
userScript: unEvalPropertyValue,
entity,
globalData: globalData.getGlobalData(true),
});
set(lintTreeErrors, `["${triggerPath}"]`, lintErrors);
});
// lint binding paths that need GLOBAL_DATA_WITH_FUNCTIONS
if (bindingPathsRequiringFunctions.size) {
bindingPathsRequiringFunctions.forEach((fullPropertyPath) => {
const { entityName } = getEntityNameAndPropertyPath(fullPropertyPath);
const entity = unEvalTree[entityName];
const unEvalPropertyValue = get(
unEvalTree,
fullPropertyPath,
) as unknown as string;
// remove all lint errors from path
set(lintTreeErrors, `["${fullPropertyPath}"]`, []);
const lintErrors = lintBindingPath({
dynamicBinding: unEvalPropertyValue,
entity,
fullPropertyPath,
globalData: evalContext,
});
set(lintTreeErrors, `["${fullPropertyPath}"]`, lintErrors);
});
}
// Lint triggerPaths
if (triggerPaths.size) {
triggerPaths.forEach((triggerPath) => {
const { entityName } = getEntityNameAndPropertyPath(triggerPath);
const entity = unEvalTree[entityName];
const unEvalPropertyValue = get(
unEvalTree,
triggerPath,
) as unknown as string;
// remove all lint errors from path
set(lintTreeErrors, `["${triggerPath}"]`, []);
const lintErrors = lintTriggerPath({
globalData: evalContext,
userScript: unEvalPropertyValue,
entity,
fullPropertyPath: triggerPath,
});
set(lintTreeErrors, `["${triggerPath}"]`, lintErrors);
});
}
// Lint jsobject paths
if (jsObjectPaths.size) {
jsObjectPaths.forEach((jsObjectPath) => {
const { entityName: jsObjectName } =
getEntityNameAndPropertyPath(jsObjectPath);
const jsObjectState = get(jsPropertiesState, jsObjectName);
const jsObjectBodyPath = `["${jsObjectName}.body"]`;
updatedJSEntities.add(jsObjectName);
// An empty state shows that there is a parse error in the jsObject or the object is empty, so we lint the entire body
// instead of an individual properties
if (isEmpty(jsObjectState)) {
const jsObjectBodyLintErrors = lintJSObjectBody(
jsObjectName,
globalData.getGlobalData(true),
);
set(lintTreeErrors, jsObjectBodyPath, jsObjectBodyLintErrors);
} else if (jsObjectPath !== "body") {
const propertyLintErrors = lintJSObjectProperty(
jsObjectPath,
jsObjectState,
asyncJSFunctionsInDataFields,
);
const currentLintErrorsInBody = get(
lintTreeErrors,
jsObjectBodyPath,
[] as LintError[],
);
const updatedLintErrors = [
...currentLintErrorsInBody,
...propertyLintErrors,
];
set(lintTreeErrors, jsObjectBodyPath, updatedLintErrors);
}
});
}
return lintTreeErrors;
return {
errors: lintTreeErrors,
updatedJSEntities: Array.from(updatedJSEntities),
};
}

View File

@ -64,18 +64,32 @@ function eventRequestHandler({
}): LintTreeResponse | unknown {
switch (method) {
case LINT_WORKER_ACTIONS.LINT_TREE: {
const lintTreeResponse: LintTreeResponse = { errors: {} };
const lintTreeResponse: LintTreeResponse = {
errors: {},
updatedJSEntities: [],
};
try {
const { cloudHosting, configTree, pathsToLint, unevalTree } =
requestData as LintTreeRequest;
const lintErrors = getlintErrorsFromTree(
pathsToLint,
unevalTree,
configTree,
const {
asyncJSFunctionsInDataFields,
cloudHosting,
configTree,
jsPropertiesState,
pathsToLint,
unevalTree: unEvalTree,
} = requestData as LintTreeRequest;
const { errors: lintErrors, updatedJSEntities } = getlintErrorsFromTree(
{
pathsToLint,
unEvalTree,
jsPropertiesState,
cloudHosting,
asyncJSFunctionsInDataFields,
configTree,
},
);
lintTreeResponse.errors = lintErrors;
lintTreeResponse.updatedJSEntities = updatedJSEntities;
} catch (e) {}
return lintTreeResponse;
}
@ -97,6 +111,7 @@ function eventRequestHandler({
}
return true;
}
default: {
// eslint-disable-next-line no-console
console.error("Action not registered on lintWorker ", method);

View File

@ -1,6 +1,16 @@
import type { ConfigTree, DataTree } from "entities/DataTree/dataTreeFactory";
import type { LintErrors } from "reducers/lintingReducers/lintErrorsReducers";
import type {
ConfigTree,
DataTree,
DataTreeEntity,
} from "entities/DataTree/dataTreeFactory";
import type { LintErrorsStore } from "reducers/lintingReducers/lintErrorsReducers";
import type { WorkerRequest } from "@appsmith/workers/common/types";
import type {
createEvaluationContext,
EvaluationScriptType,
} from "workers/Evaluation/evaluate";
import type { DependencyMap } from "utils/DynamicBindingUtils";
import type { TJSPropertiesState } from "workers/Evaluation/JSObject/jsPropertiesState";
export enum LINT_WORKER_ACTIONS {
LINT_TREE = "LINT_TREE",
@ -8,14 +18,17 @@ export enum LINT_WORKER_ACTIONS {
}
export interface LintTreeResponse {
errors: LintErrors;
errors: LintErrorsStore;
updatedJSEntities: string[];
}
export interface LintTreeRequest {
pathsToLint: string[];
unevalTree: DataTree;
jsPropertiesState: TJSPropertiesState;
configTree: ConfigTree;
cloudHosting: boolean;
asyncJSFunctionsInDataFields: DependencyMap;
}
export type LintWorkerRequest = WorkerRequest<
@ -26,5 +39,54 @@ export type LintWorkerRequest = WorkerRequest<
export type LintTreeSagaRequestData = {
pathsToLint: string[];
unevalTree: DataTree;
jsPropertiesState: TJSPropertiesState;
asyncJSFunctionsInDataFields: DependencyMap;
configTree: ConfigTree;
};
export interface lintTriggerPathProps {
userScript: string;
entity: DataTreeEntity;
globalData: ReturnType<typeof createEvaluationContext>;
}
export interface lintBindingPathProps {
dynamicBinding: string;
entity: DataTreeEntity;
fullPropertyPath: string;
globalData: ReturnType<typeof createEvaluationContext>;
}
export interface getLintingErrorsProps {
script: string;
data: Record<string, unknown>;
// {{user's code}}
originalBinding: string;
scriptType: EvaluationScriptType;
options?: {
isJsObject: boolean;
};
}
export interface getlintErrorsFromTreeProps {
pathsToLint: string[];
unEvalTree: DataTree;
jsPropertiesState: TJSPropertiesState;
cloudHosting: boolean;
asyncJSFunctionsInDataFields: DependencyMap;
configTree: ConfigTree;
}
export interface getlintErrorsFromTreeResponse {
errors: LintErrorsStore;
updatedJSEntities: string[];
}
export interface initiateLintingProps {
asyncJSFunctionsInDataFields: DependencyMap;
lintOrder: string[];
unevalTree: DataTree;
requiresLinting: boolean;
jsPropertiesState: TJSPropertiesState;
configTree: ConfigTree;
}

View File

@ -1,30 +1,27 @@
import type {
ConfigTree,
DataTree,
DataTreeEntity,
DataTreeEntityConfig,
ConfigTree,
} from "entities/DataTree/dataTreeFactory";
import type { Position } from "codemirror";
import type { LintError } from "utils/DynamicBindingUtils";
import {
isDynamicValue,
isPathADynamicBinding,
PropertyEvaluationErrorType,
} from "utils/DynamicBindingUtils";
import type { DependencyMap } from "utils/DynamicBindingUtils";
import { MAIN_THREAD_ACTION } from "@appsmith/workers/Evaluation/evalWorkerActions";
import type { LintError as JSHintError } from "jshint";
import { JSHINT as jshint } from "jshint";
import { get, isEmpty, isNumber, keys, last } from "lodash";
import type { LintError as JSHintError } from "jshint";
import { isEmpty, isNil, isNumber, keys, last } from "lodash";
import type { MemberExpressionData } from "@shared/ast";
import {
extractInvalidTopLevelMemberExpressionsFromCode,
isLiteralNode,
} from "@shared/ast";
import { getDynamicBindings } from "utils/DynamicBindingUtils";
import type { createEvaluationContext } from "workers/Evaluation/evaluate";
import {
getDynamicBindings,
PropertyEvaluationErrorType,
} from "utils/DynamicBindingUtils";
import {
createEvaluationContext,
EvaluationScripts,
EvaluationScriptType,
getScriptToEval,
@ -33,13 +30,11 @@ import {
} from "workers/Evaluation/evaluate";
import {
getEntityNameAndPropertyPath,
isAction,
isATriggerPath,
isDataTreeEntity,
isDynamicLeaf,
isJSAction,
isWidget,
} from "@appsmith/workers/Evaluation/evaluationUtils";
import { Severity } from "entities/AppsmithConsole";
import { JSLibraries } from "workers/common/JSLibrary";
import { WorkerMessenger } from "workers/Evaluation/fns/utils/Messenger";
import {
asyncActionInSyncFieldLintMessage,
@ -48,19 +43,33 @@ import {
IDENTIFIER_NOT_DEFINED_LINT_ERROR_CODE,
IGNORED_LINT_ERRORS,
INVALID_JSOBJECT_START_STATEMENT,
INVALID_JSOBJECT_START_STATEMENT_ERROR_CODE,
JS_OBJECT_START_STATEMENT,
lintOptions,
SUPPORTED_WEB_APIS,
WARNING_LINT_ERRORS,
} from "./constants";
import { APPSMITH_GLOBAL_FUNCTIONS } from "components/editorComponents/ActionCreator/constants";
import type {
getLintingErrorsProps,
initiateLintingProps,
lintBindingPathProps,
LintTreeSagaRequestData,
lintTriggerPathProps,
} from "./types";
import { JSLibraries } from "workers/common/JSLibrary";
import { Severity } from "entities/AppsmithConsole";
import {
entityFns,
getActionTriggerFunctionNames,
} from "workers/Evaluation/fns";
import type {
TJSFunctionPropertyState,
TJSpropertyState,
} from "workers/Evaluation/JSObject/jsPropertiesState";
import type { JSActionEntity } from "entities/DataTree/types";
import { globalData } from "./globalData";
interface lintBindingPathProps {
dynamicBinding: string;
entity: DataTreeEntity;
fullPropertyPath: string;
globalData: ReturnType<typeof createEvaluationContext>;
}
export function lintBindingPath({
dynamicBinding,
entity,
@ -68,30 +77,6 @@ export function lintBindingPath({
globalData,
}: lintBindingPathProps) {
let lintErrors: LintError[] = [];
if (isJSAction(entity)) {
if (!entity.body) return lintErrors;
if (!entity.body.startsWith(JS_OBJECT_START_STATEMENT)) {
return lintErrors.concat([
{
errorType: PropertyEvaluationErrorType.LINT,
errorSegment: "",
originalBinding: entity.body,
line: 0,
ch: 0,
code: entity.body,
variables: [],
raw: entity.body,
errorMessage: {
name: "LintingError",
message: INVALID_JSOBJECT_START_STATEMENT,
},
severity: Severity.ERROR,
},
]);
}
}
const { propertyPath } = getEntityNameAndPropertyPath(fullPropertyPath);
// Get the {{binding}} bound values
const { jsSnippets, stringSegments } = getDynamicBindings(
@ -116,8 +101,6 @@ export function lintBindingPath({
data: globalData,
originalBinding,
scriptType,
entity,
fullPropertyPath,
});
lintErrors = lintErrors.concat(lintErrorsFromSnippet);
}
@ -125,15 +108,9 @@ export function lintBindingPath({
}
return lintErrors;
}
interface lintTriggerPathProps {
userScript: string;
entity: DataTreeEntity;
globalData: ReturnType<typeof createEvaluationContext>;
fullPropertyPath: string;
}
export function lintTriggerPath({
entity,
fullPropertyPath,
globalData,
userScript,
}: lintTriggerPathProps) {
@ -145,35 +122,9 @@ export function lintTriggerPath({
data: globalData,
originalBinding: jsSnippets[0],
scriptType: EvaluationScriptType.TRIGGERS,
entity,
fullPropertyPath,
});
}
export function pathRequiresLinting(
dataTree: DataTree,
entity: DataTreeEntity,
fullPropertyPath: string,
entityConfig: DataTreeEntityConfig,
): boolean {
const { propertyPath } = getEntityNameAndPropertyPath(fullPropertyPath);
const unEvalPropertyValue = get(
dataTree,
fullPropertyPath,
) as unknown as string;
if (isATriggerPath(entityConfig, propertyPath)) {
return isDynamicValue(unEvalPropertyValue);
}
const isADynamicBindingPath =
(isAction(entity) || isWidget(entity) || isJSAction(entity)) &&
isPathADynamicBinding(entityConfig, propertyPath);
const requiresLinting =
(isADynamicBindingPath && isDynamicValue(unEvalPropertyValue)) ||
isJSAction(entity);
return requiresLinting;
}
// Removes "export default" statement from js Object
export function getJSToLint(
entity: DataTreeEntity,
@ -278,19 +229,26 @@ function sanitizeJSHintErrors(
return result;
}, []);
}
const getLintSeverity = (code: string): Severity.WARNING | Severity.ERROR => {
const getLintSeverity = (
code: string,
errorMessage: string,
): Severity.WARNING | Severity.ERROR => {
const severity =
code in WARNING_LINT_ERRORS ? Severity.WARNING : Severity.ERROR;
code in WARNING_LINT_ERRORS ||
errorMessage === asyncActionInSyncFieldLintMessage(true)
? Severity.WARNING
: Severity.ERROR;
return severity;
};
const getLintErrorMessage = (
reason: string,
code: string,
variables: string[],
isJSObject = false,
): string => {
switch (code) {
case IDENTIFIER_NOT_DEFINED_LINT_ERROR_CODE: {
return getRefinedW117Error(variables[0], reason);
return getRefinedW117Error(variables[0], reason, isJSObject);
}
default: {
return reason;
@ -302,6 +260,7 @@ function convertJsHintErrorToAppsmithLintError(
script: string,
originalBinding: string,
scriptPos: Position,
isJSObject = false,
): LintError {
const { a, b, c, code, d, evidence, reason } = jsHintError;
@ -311,14 +270,20 @@ function convertJsHintErrorToAppsmithLintError(
jsHintError.line === scriptPos.line
? jsHintError.character - scriptPos.ch
: jsHintError.character;
const lintErrorMessage = getLintErrorMessage(
reason,
code,
[a, b, c, d],
isJSObject,
);
return {
errorType: PropertyEvaluationErrorType.LINT,
raw: script,
severity: getLintSeverity(code),
severity: getLintSeverity(code, lintErrorMessage),
errorMessage: {
name: "LintingError",
message: getLintErrorMessage(reason, code, [a, b, c, d]),
message: lintErrorMessage,
},
errorSegment: evidence,
originalBinding,
@ -329,17 +294,10 @@ function convertJsHintErrorToAppsmithLintError(
ch: actualErrorCh,
};
}
interface getLintingErrorsProps {
script: string;
data: Record<string, unknown>;
// {{user's code}}
originalBinding: string;
scriptType: EvaluationScriptType;
entity: DataTreeEntity;
fullPropertyPath: string;
}
export function getLintingErrors({
data,
options,
originalBinding,
script,
scriptType,
@ -356,6 +314,7 @@ export function getLintingErrors({
script,
originalBinding,
scriptPos,
options?.isJsObject,
),
);
const invalidPropertyErrors = getInvalidPropertyErrorsFromScript(
@ -363,6 +322,7 @@ export function getLintingErrors({
data,
scriptPos,
originalBinding,
options?.isJsObject,
);
return jshintErrors.concat(invalidPropertyErrors);
}
@ -373,6 +333,7 @@ function getInvalidPropertyErrorsFromScript(
data: Record<string, unknown>,
scriptPos: Position,
originalBinding: string,
isJSObject = false,
): LintError[] {
let invalidTopLevelMemberExpressions: MemberExpressionData[] = [];
try {
@ -394,15 +355,19 @@ function getInvalidPropertyErrorsFromScript(
const propertyStartColumn = !isLiteralNode(property)
? property.loc.start.column + 1
: property.loc.start.column + 2;
const lintErrorMessage = CUSTOM_LINT_ERRORS[
CustomLintErrorCode.INVALID_ENTITY_PROPERTY
](object.name, propertyName, data[object.name], isJSObject);
return {
errorType: PropertyEvaluationErrorType.LINT,
raw: script,
severity: getLintSeverity(CustomLintErrorCode.INVALID_ENTITY_PROPERTY),
severity: getLintSeverity(
CustomLintErrorCode.INVALID_ENTITY_PROPERTY,
lintErrorMessage,
),
errorMessage: {
name: "LintingError",
message: CUSTOM_LINT_ERRORS[
CustomLintErrorCode.INVALID_ENTITY_PROPERTY
](object.name, propertyName),
message: lintErrorMessage,
},
errorSegment: `${object.name}.${propertyName}`,
originalBinding,
@ -419,19 +384,24 @@ function getInvalidPropertyErrorsFromScript(
return invalidPropertyErrors;
}
export function initiateLinting(
lintOrder: string[],
unevalTree: DataTree,
requiresLinting: boolean,
configTree: ConfigTree,
) {
export function initiateLinting({
asyncJSFunctionsInDataFields,
configTree,
jsPropertiesState,
lintOrder,
requiresLinting,
unevalTree,
}: initiateLintingProps) {
const data = {
pathsToLint: lintOrder,
unevalTree,
jsPropertiesState,
asyncJSFunctionsInDataFields,
configTree,
} as LintTreeSagaRequestData;
if (!requiresLinting) return;
WorkerMessenger.ping({
data: {
lintOrder,
unevalTree,
configTree,
},
data,
method: MAIN_THREAD_ACTION.LINT_TREE,
});
}
@ -439,14 +409,227 @@ export function initiateLinting(
export function getRefinedW117Error(
undefinedVar: string,
originalReason: string,
isJsObject = false,
) {
// Refine error message for await using in field not marked as async
if (undefinedVar === "await") {
return "'await' expressions are only allowed within async functions. Did you mean to mark this function as 'async'?";
}
// Handle case where platform functions are used in sync fields
// Handle case where platform functions are used in data fields
if (APPSMITH_GLOBAL_FUNCTIONS.hasOwnProperty(undefinedVar)) {
return asyncActionInSyncFieldLintMessage(undefinedVar);
return asyncActionInSyncFieldLintMessage(isJsObject);
}
return originalReason;
}
export function lintJSProperty(
jsPropertyFullName: string,
jsPropertyState: TJSpropertyState,
globalData: DataTree,
): LintError[] {
if (isNil(jsPropertyState)) {
return [];
}
const { propertyPath: jsPropertyPath } =
getEntityNameAndPropertyPath(jsPropertyFullName);
const scriptType = getScriptType(false, false);
const scriptToLint = getScriptToEval(
jsPropertyState.value,
EvaluationScriptType.OBJECT_PROPERTY,
);
const propLintErrors = getLintingErrors({
script: scriptToLint,
data: globalData,
originalBinding: jsPropertyState.value,
scriptType,
options: { isJsObject: true },
});
const refinedErrors = propLintErrors.map((lintError) => {
return {
...lintError,
line: lintError.line + jsPropertyState.position.startLine - 1,
ch:
lintError.line === 0
? lintError.ch + jsPropertyState.position.startColumn
: lintError.ch,
originalPath: jsPropertyPath,
};
});
return refinedErrors;
}
export function lintJSObjectProperty(
jsPropertyFullName: string,
jsObjectState: Record<string, TJSpropertyState>,
asyncJSFunctionsInDataFields: DependencyMap,
) {
let lintErrors: LintError[] = [];
const { propertyPath: jsPropertyName } =
getEntityNameAndPropertyPath(jsPropertyFullName);
const jsPropertyState = jsObjectState[jsPropertyName];
const isAsyncJSFunctionBoundToSyncField =
asyncJSFunctionsInDataFields.hasOwnProperty(jsPropertyFullName);
const jsPropertyLintErrors = lintJSProperty(
jsPropertyFullName,
jsPropertyState,
globalData.getGlobalData(!isAsyncJSFunctionBoundToSyncField),
);
lintErrors = lintErrors.concat(jsPropertyLintErrors);
// if function is async, and bound to a data field, then add custom lint error
if (isAsyncJSFunctionBoundToSyncField) {
lintErrors.push(
generateAsyncFunctionBoundToDataFieldCustomError(
asyncJSFunctionsInDataFields[jsPropertyFullName],
jsPropertyState,
jsPropertyFullName,
),
);
}
return lintErrors;
}
export function lintJSObjectBody(
jsObjectName: string,
globalData: DataTree,
): LintError[] {
const jsObject = globalData[jsObjectName];
const rawJSObjectbody = (jsObject as unknown as JSActionEntity).body;
if (!rawJSObjectbody) return [];
if (!rawJSObjectbody.startsWith(JS_OBJECT_START_STATEMENT)) {
return [
{
errorType: PropertyEvaluationErrorType.LINT,
errorSegment: "",
originalBinding: rawJSObjectbody,
line: 0,
ch: 0,
code: INVALID_JSOBJECT_START_STATEMENT_ERROR_CODE,
variables: [],
raw: rawJSObjectbody,
errorMessage: {
name: "LintingError",
message: INVALID_JSOBJECT_START_STATEMENT,
},
severity: Severity.ERROR,
},
];
}
const scriptType = getScriptType(false, false);
const jsbodyToLint = getJSToLint(jsObject, rawJSObjectbody, "body"); // remove "export default"
const scriptToLint = getScriptToEval(jsbodyToLint, scriptType);
return getLintingErrors({
script: scriptToLint,
data: globalData,
originalBinding: jsbodyToLint,
scriptType,
});
}
export function getEvaluationContext(
unevalTree: DataTree,
cloudHosting: boolean,
options: { withFunctions: boolean },
) {
if (!options.withFunctions)
return createEvaluationContext({
dataTree: unevalTree,
resolvedFunctions: {},
isTriggerBased: false,
removeEntityFunctions: true,
});
const evalContext = createEvaluationContext({
dataTree: unevalTree,
resolvedFunctions: {},
isTriggerBased: false,
removeEntityFunctions: false,
});
const platformFnNamesMap = Object.values(
getActionTriggerFunctionNames(cloudHosting),
).reduce(
(acc, name) => ({ ...acc, [name]: true }),
{} as { [x: string]: boolean },
);
Object.assign(evalContext, platformFnNamesMap);
return evalContext;
}
export function sortLintingPathsByType(
pathsToLint: string[],
unevalTree: DataTree,
configTree: ConfigTree,
) {
const triggerPaths = new Set<string>();
const bindingPaths = new Set<string>();
const jsObjectPaths = new Set<string>();
for (const fullPropertyPath of pathsToLint) {
const { entityName, propertyPath } =
getEntityNameAndPropertyPath(fullPropertyPath);
const entity = unevalTree[entityName];
const entityConfig = configTree[entityName];
// We are only interested in dynamic leaves
if (!isDynamicLeaf(unevalTree, fullPropertyPath, configTree)) continue;
if (isATriggerPath(entityConfig, propertyPath)) {
triggerPaths.add(fullPropertyPath);
continue;
}
if (isJSAction(entity)) {
jsObjectPaths.add(fullPropertyPath);
continue;
}
bindingPaths.add(fullPropertyPath);
}
return { triggerPaths, bindingPaths, jsObjectPaths };
}
function generateAsyncFunctionBoundToDataFieldCustomError(
dataFieldBindings: string[],
jsPropertyState: TJSpropertyState,
jsPropertyFullName: string,
): LintError {
const { propertyPath: jsPropertyName } =
getEntityNameAndPropertyPath(jsPropertyFullName);
const lintErrorMessage =
CUSTOM_LINT_ERRORS.ASYNC_FUNCTION_BOUND_TO_SYNC_FIELD(
dataFieldBindings,
jsPropertyFullName,
(jsPropertyState as TJSFunctionPropertyState).isMarkedAsync,
);
return {
errorType: PropertyEvaluationErrorType.LINT,
raw: jsPropertyState.value,
severity: getLintSeverity(
CustomLintErrorCode.ASYNC_FUNCTION_BOUND_TO_SYNC_FIELD,
lintErrorMessage,
),
errorMessage: {
name: "LintingError",
message: lintErrorMessage,
},
errorSegment: jsPropertyFullName,
originalBinding: jsPropertyState.value,
// By keeping track of these variables we can highlight the exact text that caused the error.
variables: [jsPropertyName, null, null, null],
code: CustomLintErrorCode.ASYNC_FUNCTION_BOUND_TO_SYNC_FIELD,
line: jsPropertyState.position.keyStartLine - 1,
ch: jsPropertyState.position.keyStartColumn + 1,
originalPath: jsPropertyName,
};
}
export function isEntityFunction(entity: unknown, propertyName: string) {
if (!isDataTreeEntity(entity)) return false;
return entityFns.find(
(entityFn) =>
entityFn.name === propertyName &&
entityFn.qualifier(entity as DataTreeEntity),
);
}

View File

@ -97,7 +97,10 @@ import {
getUpdatedLocalUnEvalTreeAfterJSUpdates,
parseJSActions,
} from "workers/Evaluation/JSObject";
import { getFixedTimeDifference } from "./utils";
import {
addRootcauseToAsyncInvocationErrors,
getFixedTimeDifference,
} from "./utils";
import { isJSObjectFunction } from "workers/Evaluation/JSObject/utils";
import {
getValidatedTree,
@ -1063,7 +1066,7 @@ export default class DataTreeEvaluator {
});
}
const result = this.evaluateDynamicBoundValue(
const { errors: evalErrors, result } = this.evaluateDynamicBoundValue(
toBeSentForEval,
data,
resolvedFunctions,
@ -1071,16 +1074,20 @@ export default class DataTreeEvaluator {
contextData,
callBackData,
);
if (fullPropertyPath && result.errors.length) {
if (fullPropertyPath && evalErrors.length) {
addErrorToEntityProperty({
errors: result.errors,
errors: addRootcauseToAsyncInvocationErrors(
fullPropertyPath,
configTree,
evalErrors,
),
evalProps: this.evalProps,
fullPropertyPath,
dataTree: data,
configTree,
});
}
return result.result;
return result;
} else {
return stringSegments[index];
}

View File

@ -1,3 +1,55 @@
import {
getEntityNameAndPropertyPath,
isAction,
isJSAction,
isWidget,
} from "@appsmith/workers/Evaluation/evaluationUtils";
import type {
ConfigTree,
DataTreeEntity,
WidgetEntity,
} from "entities/DataTree/dataTreeFactory";
import type { ActionEntity, JSActionEntity } from "entities/DataTree/types";
import type { EvaluationError } from "utils/DynamicBindingUtils";
import { errorModifier } from "workers/Evaluation/errorModifier";
import { asyncJsFunctionInDataFields } from "workers/Evaluation/JSObject/asyncJSFunctionBoundToDataField";
export function getFixedTimeDifference(endTime: number, startTime: number) {
return (endTime - startTime).toFixed(2) + " ms";
}
export function isDataField(fullPath: string, configTree: ConfigTree) {
const { entityName, propertyPath } = getEntityNameAndPropertyPath(fullPath);
const entityConfig = configTree[entityName];
if ("triggerPaths" in entityConfig) {
return !(propertyPath in entityConfig.triggerPaths);
}
return false;
}
export function isWidgetActionOrJsObject(
entity: DataTreeEntity,
): entity is ActionEntity | WidgetEntity | JSActionEntity {
return isWidget(entity) || isAction(entity) || isJSAction(entity);
}
export function addRootcauseToAsyncInvocationErrors(
fullPropertyPath: string,
configTree: ConfigTree,
errors: EvaluationError[],
) {
let updatedErrors = errors;
if (isDataField(fullPropertyPath, configTree)) {
const asyncFunctionBindingInPath =
asyncJsFunctionInDataFields.getAsyncFunctionBindingInDataField(
fullPropertyPath,
);
if (asyncFunctionBindingInPath) {
updatedErrors = errorModifier.setAsyncInvocationErrorsRootcause(
errors,
asyncFunctionBindingInPath,
);
}
}
return updatedErrors;
}

View File

@ -37,7 +37,9 @@ import {
updateMap,
} from "./utils";
import type DataTreeEvaluator from "workers/common/DataTreeEvaluator";
import { difference, isEmpty, set } from "lodash";
import { difference, isEmpty, set, uniq } from "lodash";
import { isWidgetActionOrJsObject } from "../DataTreeEvaluator/utils";
import { asyncJsFunctionInDataFields } from "workers/Evaluation/JSObject/asyncJSFunctionBoundToDataField";
interface CreateDependencyMap {
dependencyMap: DependencyMap;
@ -100,11 +102,18 @@ export function createDependencyMap(
const { errors, invalidReferences, validReferences } =
extractInfoFromBindings(dependencyMap[key], dataTreeEvalRef.allKeys);
dependencyMap[key] = validReferences;
// To keep invalidReferencesMap as minimal as possible, only paths with invalid references
// are stored.
if (invalidReferences.length) {
invalidReferencesMap[key] = invalidReferences;
}
updateMap(invalidReferencesMap, key, invalidReferences, {
deleteOnEmpty: true,
replaceValue: true,
});
asyncJsFunctionInDataFields.update(
key,
validReferences,
unEvalTree,
configTree,
);
errors.forEach((error) => {
dataTreeEvalRef.errors.push(error);
});
@ -169,11 +178,12 @@ export const updateDependencyMap = ({
let didUpdateValidationDependencyMap = false;
const dependenciesOfRemovedPaths: Array<string> = [];
const removedPaths: Array<string> = [];
const extraPathsToLint = new Set<string>();
let extraPathsToLint: string[] = [];
const pathsToClearErrorsFor: any[] = [];
const {
dependencyMap,
invalidReferencesMap,
inverseDependencyMap,
oldConfigTree,
oldUnEvalTree,
triggerFieldDependencyMap,
@ -207,7 +217,7 @@ export const updateDependencyMap = ({
if (entityType !== "noop") {
switch (event) {
case DataTreeDiffEvent.NEW: {
if (isWidget(entity) || isAction(entity) || isJSAction(entity)) {
if (isWidgetActionOrJsObject(entity)) {
if (!isDynamicLeaf(unEvalDataTree, fullPropertyPath, configTree)) {
const entityDependencyMap: DependencyMap = listEntityDependencies(
entity,
@ -236,6 +246,19 @@ export const updateDependencyMap = ({
invalidReferences,
{ deleteOnEmpty: true, replaceValue: true },
);
// Update asyncJSFunctionsInDatafieldsMap
const updatedAsyncJSFunctions =
asyncJsFunctionInDataFields.update(
entityDependent,
validReferences,
unEvalDataTree,
configTree,
);
extraPathsToLint = extraPathsToLint.concat(
updatedAsyncJSFunctions,
);
dataTreeEvalErrors = dataTreeEvalErrors.concat(
extractDependencyErrors,
);
@ -367,7 +390,7 @@ export const updateDependencyMap = ({
if (isChildPropertyPath(fullPropertyPath, invalidReference)) {
updateMap(newlyValidReferencesMap, invalidReference, [path]);
if (!dependencyMap[invalidReference]) {
extraPathsToLint.add(path);
extraPathsToLint.push(path);
}
}
});
@ -403,6 +426,17 @@ export const updateDependencyMap = ({
fullPath,
validReferences,
);
// Update asyncJSMap
const updatedAsyncJSFunctions =
asyncJsFunctionInDataFields.update(
fullPath,
validReferences,
unEvalDataTree,
configTree,
);
extraPathsToLint = extraPathsToLint.concat(
updatedAsyncJSFunctions,
);
// Since the previously invalid reference has become valid,
// remove it from the invalidReferencesMap
@ -434,7 +468,7 @@ export const updateDependencyMap = ({
if (
isChildPropertyPath(fullPropertyPath, triggerPathDependency)
) {
extraPathsToLint.add(triggerPath);
extraPathsToLint.push(triggerPath);
}
},
);
@ -463,7 +497,7 @@ export const updateDependencyMap = ({
}
if (
(isWidget(entity) || isAction(entity) || isJSAction(entity)) &&
isWidgetActionOrJsObject(entity) &&
fullPropertyPath === entityName
) {
const entityDependencies = listEntityDependencies(
@ -502,6 +536,7 @@ export const updateDependencyMap = ({
didUpdateValidationDependencyMap = true;
}
}
// Either an existing entity or an existing property path has been deleted. Update the global dependency map
// by removing the bindings from the same.
Object.keys(dependencyMap).forEach((dependencyPath) => {
@ -532,7 +567,7 @@ export const updateDependencyMap = ({
if (
isChildPropertyPath(fullPropertyPath, invalidReference)
) {
extraPathsToLint.add(dependencyPath);
extraPathsToLint.push(dependencyPath);
}
},
);
@ -571,7 +606,7 @@ export const updateDependencyMap = ({
if (
isChildPropertyPath(fullPropertyPath, invalidReference)
) {
extraPathsToLint.add(dependencyPath);
extraPathsToLint.push(dependencyPath);
}
},
);
@ -579,15 +614,21 @@ export const updateDependencyMap = ({
}
});
// update asyncJsFunctionInDataFields
const updatedAsyncJSFunctions =
asyncJsFunctionInDataFields.handlePathDeletion(
fullPropertyPath,
unEvalDataTree,
configTree,
);
extraPathsToLint = extraPathsToLint.concat(updatedAsyncJSFunctions);
break;
}
case DataTreeDiffEvent.EDIT: {
// We only care if the difference is in dynamic bindings since static values do not need
// an evaluation.
if (
(isWidget(entity) || isAction(entity) || isJSAction(entity)) &&
typeof value === "string"
) {
if (isWidgetActionOrJsObject(entity) && typeof value === "string") {
const entity: ActionEntity | WidgetEntity | JSActionEntity =
unEvalDataTree[entityName] as
| ActionEntity
@ -623,7 +664,18 @@ export const updateDependencyMap = ({
dataTreeEvalErrors = dataTreeEvalErrors.concat(
extractDependencyErrors,
);
// update asyncFunctionInSyncfieldsMap
const updatedAsyncJSFunctions =
asyncJsFunctionInDataFields.handlePathEdit(
fullPropertyPath,
validReferences,
unEvalDataTree,
inverseDependencyMap,
configTree,
);
extraPathsToLint = extraPathsToLint.concat(
updatedAsyncJSFunctions,
);
// We found a new dynamic binding for this property path. We update the dependency map by overwriting the
// dependencies for this property path with the newly found dependencies
@ -673,6 +725,18 @@ export const updateDependencyMap = ({
didUpdateDependencyMap = true;
delete dependencyMap[fullPropertyPath];
delete invalidReferencesMap[fullPropertyPath];
// update asyncFunctionInSyncfieldsMap
const updatedAsyncJSFunctions =
asyncJsFunctionInDataFields.handlePathEdit(
fullPropertyPath,
[],
unEvalDataTree,
inverseDependencyMap,
configTree,
);
extraPathsToLint = extraPathsToLint.concat(
updatedAsyncJSFunctions,
);
}
}
if (
@ -783,6 +847,6 @@ export const updateDependencyMap = ({
pathsToClearErrorsFor,
dependenciesOfRemovedPaths,
removedPaths,
extraPathsToLint: Array.from(extraPathsToLint),
extraPathsToLint: uniq(extraPathsToLint),
};
};

View File

@ -13,12 +13,13 @@ import {
getEntityNameAndPropertyPath,
isAction,
isJSAction,
isJSActionConfig,
isWidget,
} from "@appsmith/workers/Evaluation/evaluationUtils";
import type {
ConfigTree,
DataTree,
ConfigTree,
DataTreeEntity,
DataTreeEntityConfig,
WidgetEntity,
@ -427,3 +428,24 @@ export function updateMap(
map[path] = updatedEntries;
}
}
export function isAsyncJSFunction(configTree: ConfigTree, fullPath: string) {
const { entityName, propertyPath } = getEntityNameAndPropertyPath(fullPath);
const configEntity = configTree[entityName];
return (
isJSActionConfig(configEntity) &&
propertyPath &&
propertyPath in configEntity.meta &&
configEntity.meta[propertyPath].isAsync
);
}
export function isJSFunction(configTree: ConfigTree, fullPath: string) {
const { entityName, propertyPath } = getEntityNameAndPropertyPath(fullPath);
const entityConfig = configTree[entityName];
return (
isJSActionConfig(entityConfig) &&
propertyPath &&
propertyPath in entityConfig.meta
);
}

View File

@ -6816,7 +6816,7 @@ acorn-walk@^7.0.0, acorn-walk@^7.1.1, acorn-walk@^7.2.0:
version "7.2.0"
resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz"
acorn-walk@^8.1.1, acorn-walk@^8.2.0:
acorn-walk@^8.1.1:
version "8.2.0"
resolved "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz"
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
@ -7363,11 +7363,6 @@ astral-regex@^2.0.0:
version "2.0.0"
resolved "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz"
astring@^1.7.5:
version "1.8.3"
resolved "https://registry.yarnpkg.com/astring/-/astring-1.8.3.tgz#1a0ae738c7cc558f8e5ddc8e3120636f5cebcb85"
integrity sha512-sRpyiNrx2dEYIMmUXprS8nlpRg2Drs8m9ElX9vVEXaCB4XEAJhKfs7IcX0IwShjuOAjLR6wzIrgoptz1n19i1A==
async-limiter@~1.0.0:
version "1.0.1"
resolved "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz"

View File

@ -21,7 +21,12 @@ import {
import { ECMA_VERSION, SourceType, NodeTypes } from "./src/constants";
// JSObjects
import { parseJSObjectWithAST } from "./src/jsObject";
import {
parseJSObject,
isJSFunctionProperty,
TParsedJSProperty,
JSPropertyPosition,
} from "./src/jsObject";
// types or intefaces should be exported with type keyword, while enums can be exported like normal functions
export type {
@ -29,6 +34,8 @@ export type {
PropertyNode,
MemberExpressionData,
IdentifierInfo,
TParsedJSProperty,
JSPropertyPosition,
};
export {
@ -44,8 +51,9 @@ export {
extractInvalidTopLevelMemberExpressionsFromCode,
getFunctionalParamsFromNode,
isTypeOfFunction,
parseJSObjectWithAST,
parseJSObject,
ECMA_VERSION,
SourceType,
NodeTypes,
isJSFunctionProperty,
};

View File

@ -20,9 +20,11 @@
"link-package": "yarn install && rollup -c && cd build && cp -R ../node_modules ./node_modules && yarn link"
},
"dependencies": {
"@babel/runtime": "^7.21.0",
"acorn": "^8.8.0",
"acorn-walk": "^8.2.0",
"astring": "^1.7.5",
"escodegen": "^2.0.0",
"lodash": "^4.17.21",
"rollup": "^2.77.0",
"typescript": "4.5.5",
@ -31,6 +33,7 @@
"devDependencies": {
"@babel/preset-typescript": "^7.17.12",
"@rollup/plugin-commonjs": "^22.0.0",
"@types/escodegen": "^0.0.7",
"@types/jest": "29.0.3",
"@types/lodash": "^4.14.120",
"@typescript-eslint/eslint-plugin": "^5.25.0",

View File

@ -1,5 +1,5 @@
import { parseJSObject } from "../index";
import { extractIdentifierInfoFromCode } from "../src/index";
import { parseJSObjectWithAST } from "../src/jsObject";
describe("getAllIdentifiers", () => {
it("works properly", () => {
@ -306,7 +306,7 @@ describe("getAllIdentifiers", () => {
const { references } = extractIdentifierInfoFromCode(
perCase.script,
2,
perCase.invalidIdentifiers
perCase.invalidIdentifiers,
);
expect(references).toStrictEqual(perCase.expectedResults);
});
@ -315,7 +315,7 @@ describe("getAllIdentifiers", () => {
describe("parseJSObjectWithAST", () => {
it("parse js object", () => {
const body = `{
const body = `export default{
myVar1: [],
myVar2: {},
myFun1: () => {
@ -325,36 +325,84 @@ describe("parseJSObjectWithAST", () => {
//use async-await or promises
}
}`;
const parsedObject = [
const expectedParsedObject = [
{
key: "myVar1",
value: "[]",
rawContent: "myVar1: []",
type: "ArrayExpression",
position: {
startLine: 2,
startColumn: 1,
endLine: 2,
endColumn: 11,
keyStartLine: 2,
keyEndLine: 2,
keyStartColumn: 1,
keyEndColumn: 7,
},
},
{
key: "myVar2",
value: "{}",
rawContent: "myVar2: {}",
type: "ObjectExpression",
position: {
startLine: 3,
startColumn: 1,
endLine: 3,
endColumn: 11,
keyStartLine: 3,
keyEndLine: 3,
keyStartColumn: 1,
keyEndColumn: 7,
},
},
{
key: "myFun1",
value: "() => {}",
rawContent: "myFun1: () => {\n\t\t//write code here\n\t}",
type: "ArrowFunctionExpression",
position: {
startLine: 4,
startColumn: 1,
endLine: 6,
endColumn: 2,
keyStartLine: 4,
keyEndLine: 4,
keyStartColumn: 1,
keyEndColumn: 7,
},
arguments: [],
isMarkedAsync: false
},
{
key: "myFun2",
value: "async () => {}",
rawContent:
"myFun2: async () => {\n\t\t//use async-await or promises\n\t}",
type: "ArrowFunctionExpression",
position: {
startLine: 7,
startColumn: 1,
endLine: 9,
endColumn: 2,
keyStartLine: 7,
keyEndLine: 7,
keyStartColumn: 1,
keyEndColumn: 7,
},
arguments: [],
isMarkedAsync: true,
},
];
const resultParsedObject = parseJSObjectWithAST(body);
expect(resultParsedObject).toStrictEqual(parsedObject);
const { parsedObject } = parseJSObject(body);
expect(parsedObject).toStrictEqual(expectedParsedObject);
});
it("parse js object with literal", () => {
const body = `{
const body = `export default{
myVar1: [],
myVar2: {
"a": "app",
@ -366,36 +414,83 @@ describe("parseJSObjectWithAST", () => {
//use async-await or promises
}
}`;
const parsedObject = [
const expectedParsedObject = [
{
key: "myVar1",
value: "[]",
rawContent: "myVar1: []",
type: "ArrayExpression",
position: {
startLine: 2,
startColumn: 1,
endLine: 2,
endColumn: 11,
keyStartLine: 2,
keyEndLine: 2,
keyStartColumn: 1,
keyEndColumn: 7,
},
},
{
key: "myVar2",
value: '{\n "a": "app"\n}',
rawContent: 'myVar2: {\n\t\t"a": "app",\n\t}',
type: "ObjectExpression",
position: {
startLine: 3,
startColumn: 1,
endLine: 5,
endColumn: 2,
keyStartLine: 3,
keyEndLine: 3,
keyStartColumn: 1,
keyEndColumn: 7,
},
},
{
key: "myFun1",
value: "() => {}",
rawContent: "myFun1: () => {\n\t\t//write code here\n\t}",
type: "ArrowFunctionExpression",
position: {
startLine: 6,
startColumn: 1,
endLine: 8,
endColumn: 2,
keyStartLine: 6,
keyEndLine: 6,
keyStartColumn: 1,
keyEndColumn: 7,
},
arguments: [],
isMarkedAsync: false
},
{
key: "myFun2",
value: "async () => {}",
rawContent:
"myFun2: async () => {\n\t\t//use async-await or promises\n\t}",
type: "ArrowFunctionExpression",
position: {
startLine: 9,
startColumn: 1,
endLine: 11,
endColumn: 2,
keyStartLine: 9,
keyEndLine: 9,
keyStartColumn: 1,
keyEndColumn: 7,
},
arguments: [],
isMarkedAsync: true,
},
];
const resultParsedObject = parseJSObjectWithAST(body);
expect(resultParsedObject).toStrictEqual(parsedObject);
const { parsedObject } = parseJSObject(body);
expect(parsedObject).toStrictEqual(expectedParsedObject);
});
it("parse js object with variable declaration inside function", () => {
const body = `{
const body = `export default{
myFun1: () => {
const a = {
conditions: [],
@ -408,89 +503,108 @@ describe("parseJSObjectWithAST", () => {
//use async-await or promises
}
}`;
const parsedObject = [
const expectedParsedObject = [
{
key: "myFun1",
value: `() => {
const a = {
conditions: [],
requires: 1,
testFunc: () => {},
testFunc2: function () {}
};
}`,
value:
"() => {\n" +
" const a = {\n" +
" conditions: [],\n" +
" requires: 1,\n" +
" testFunc: () => {},\n" +
" testFunc2: function () {}\n" +
" };\n" +
"}",
rawContent:
"myFun1: () => {\n" +
" const a = {\n" +
" conditions: [],\n" +
" requires: 1,\n" +
" testFunc: () => {},\n" +
" testFunc2: function(){}\n" +
" };\n" +
" }",
type: "ArrowFunctionExpression",
position: {
startLine: 2,
startColumn: 6,
endLine: 9,
endColumn: 7,
keyStartLine: 2,
keyEndLine: 2,
keyStartColumn: 6,
keyEndColumn: 12,
},
arguments: [],
isMarkedAsync: false,
},
{
key: "myFun2",
value: "async () => {}",
rawContent:
"myFun2: async () => {\n //use async-await or promises\n }",
type: "ArrowFunctionExpression",
position: {
startLine: 10,
startColumn: 6,
endLine: 12,
endColumn: 7,
keyStartLine: 10,
keyEndLine: 10,
keyStartColumn: 6,
keyEndColumn: 12,
},
arguments: [],
isMarkedAsync: true,
},
];
const resultParsedObject = parseJSObjectWithAST(body);
expect(resultParsedObject).toStrictEqual(parsedObject);
const { parsedObject } = parseJSObject(body);
expect(parsedObject).toStrictEqual(expectedParsedObject);
});
it("parse js object with params of all types", () => {
const body = `{
const body = `export default{
myFun2: async (a,b = Array(1,2,3),c = "", d = [], e = this.myVar1, f = {}, g = function(){}, h = Object.assign({}), i = String(), j = storeValue()) => {
//use async-await or promises
},
}`;
const parsedObject = [
const expectedParsedObject = [
{
key: "myFun2",
value:
'async (a, b = Array(1, 2, 3), c = "", d = [], e = this.myVar1, f = {}, g = function () {}, h = Object.assign({}), i = String(), j = storeValue()) => {}',
rawContent:
'myFun2: async (a,b = Array(1,2,3),c = "", d = [], e = this.myVar1, f = {}, g = function(){}, h = Object.assign({}), i = String(), j = storeValue()) => {\n' +
" //use async-await or promises\n" +
" }",
type: "ArrowFunctionExpression",
position: {
startLine: 2,
startColumn: 6,
endLine: 4,
endColumn: 7,
keyStartLine: 2,
keyEndLine: 2,
keyStartColumn: 6,
keyEndColumn: 12,
},
arguments: [
{
paramName: "a",
defaultValue: undefined,
},
{
paramName: "b",
defaultValue: undefined,
},
{
paramName: "c",
defaultValue: undefined,
},
{
paramName: "d",
defaultValue: undefined,
},
{
paramName: "e",
defaultValue: undefined,
},
{
paramName: "f",
defaultValue: undefined,
},
{
paramName: "g",
defaultValue: undefined,
},
{
paramName: "h",
defaultValue: undefined,
},
{
paramName: "i",
defaultValue: undefined,
},
{
paramName: "j",
defaultValue: undefined,
},
{ paramName: "a", defaultValue: undefined },
{ paramName: "b", defaultValue: undefined },
{ paramName: "c", defaultValue: undefined },
{ paramName: "d", defaultValue: undefined },
{ paramName: "e", defaultValue: undefined },
{ paramName: "f", defaultValue: undefined },
{ paramName: "g", defaultValue: undefined },
{ paramName: "h", defaultValue: undefined },
{ paramName: "i", defaultValue: undefined },
{ paramName: "j", defaultValue: undefined },
],
isMarkedAsync: true,
},
];
const resultParsedObject = parseJSObjectWithAST(body);
expect(resultParsedObject).toEqual(parsedObject);
const { parsedObject } = parseJSObject(body);
expect(parsedObject).toStrictEqual(expectedParsedObject);
});
});

View File

@ -1,4 +1,4 @@
import { parse, Node, SourceLocation, Options, Comment } from "acorn";
import { parse, Node, SourceLocation, Options } from "acorn";
import { ancestor, simple } from "acorn-walk";
import { ECMA_VERSION, NodeTypes } from "./constants/ast";
import { has, isFinite, isString, memoize, toPath } from "lodash";
@ -36,7 +36,7 @@ interface MemberExpressionNode extends Node {
}
// doc: https://github.com/estree/estree/blob/master/es5.md#identifier
interface IdentifierNode extends Node {
export interface IdentifierNode extends Node {
type: NodeTypes.Identifier;
name: string;
}
@ -69,10 +69,12 @@ interface FunctionDeclarationNode extends Node, Function {
// doc: https://github.com/estree/estree/blob/master/es5.md#functionexpression
interface FunctionExpressionNode extends Expression, Function {
type: NodeTypes.FunctionExpression;
async: boolean;
}
interface ArrowFunctionExpressionNode extends Expression, Function {
type: NodeTypes.ArrowFunctionExpression;
async: boolean;
}
export interface ObjectExpression extends Expression {
@ -87,7 +89,7 @@ interface AssignmentPatternNode extends Node {
}
// doc: https://github.com/estree/estree/blob/master/es5.md#literal
interface LiteralNode extends Node {
export interface LiteralNode extends Node {
type: NodeTypes.Literal;
value: string | boolean | null | number | RegExp;
}
@ -107,8 +109,12 @@ export interface PropertyNode extends Node {
kind: "init" | "get" | "set";
}
export interface ExportDefaultDeclarationNode extends Node {
declaration: Node;
}
// Node with location details
type NodeWithLocation<NodeType> = NodeType & {
export type NodeWithLocation<NodeType> = NodeType & {
loc: SourceLocation;
};
@ -163,6 +169,12 @@ export const isPropertyNode = (node: Node): node is PropertyNode => {
return node.type === NodeTypes.Property;
};
export const isExportDefaultDeclarationNode = (
node: Node,
): node is ExportDefaultDeclarationNode => {
return node.type === NodeTypes.ExportDefaultDeclaration;
};
export const isPropertyAFunctionNode = (
node: Node,
): node is ArrowFunctionExpressionNode | FunctionExpressionNode => {
@ -211,7 +223,11 @@ const getFunctionalParamNamesFromNode = (
// Since this will be used by both the server and the client, we want to prevent regeneration of ast
// for the the same code snippet
export const getAST = memoize((code: string, options?: AstOptions) =>
parse(code, { ...options, ecmaVersion: ECMA_VERSION }),
parse(code, {
...options,
ecmaVersion: ECMA_VERSION,
locations: true, // Adds location data to each node
}),
);
/**

View File

@ -1,78 +1,140 @@
import { Node } from "acorn";
import { getAST } from "../index";
import { generate } from "astring";
import { simple } from "acorn-walk";
import {
getAST,
IdentifierNode,
isExportDefaultDeclarationNode,
isObjectExpression,
isPropertyNode,
isTypeOfFunction,
LiteralNode,
NodeWithLocation,
PropertyNode,
} from "../index";
import { generate } from "astring";
import {
getFunctionalParamsFromNode,
isPropertyAFunctionNode,
isVariableDeclarator,
isObjectExpression,
PropertyNode,
functionParam,
} from "../index";
type JsObjectProperty = {
key: string;
value: string;
type: string;
arguments?: Array<functionParam>;
};
import { SourceType, NodeTypes } from "../../index";
import { attachComments } from "escodegen";
import { extractContentByPosition } from "../utils";
const jsObjectVariableName =
"____INTERNAL_JS_OBJECT_NAME_USED_FOR_PARSING_____";
export const jsObjectDeclaration = `var ${jsObjectVariableName} =`;
export const parseJSObjectWithAST = (
jsObjectBody: string
): Array<JsObjectProperty> => {
/*
jsObjectVariableName value is added such actual js code would never name same variable name.
if the variable name will be same then also we won't have problem here as jsObjectVariableName will be last node in VariableDeclarator hence overriding the previous JSObjectProperties.
Keeping this just for sanity check if any caveat was missed.
*/
const jsCode = `${jsObjectDeclaration} ${jsObjectBody}`;
export interface JSPropertyPosition {
startLine: number;
startColumn: number;
endLine: number;
endColumn: number;
keyStartLine: number;
keyEndLine: number;
keyStartColumn: number;
keyEndColumn: number;
}
const ast = getAST(jsCode);
interface baseJSProperty {
key: string;
value: string;
type: string;
position: Partial<JSPropertyPosition>;
rawContent: string;
}
const parsedObjectProperties = new Set<JsObjectProperty>();
let JSObjectProperties: Array<PropertyNode> = [];
type JSFunctionProperty = baseJSProperty & {
arguments: functionParam[];
// If function uses the "async" keyword
isMarkedAsync: boolean;
};
type JSVarProperty = baseJSProperty;
export type TParsedJSProperty = JSVarProperty | JSFunctionProperty;
export const isJSFunctionProperty = (
t: TParsedJSProperty,
): t is JSFunctionProperty => {
return isTypeOfFunction(t.type);
};
export const parseJSObject = (code: string) => {
let ast: Node = { end: 0, start: 0, type: "" };
const result: TParsedJSProperty[] = [];
try {
const comments: any = [];
const token: any = [];
ast = getAST(code, {
sourceType: SourceType.module,
onComment: comments,
onToken: token,
ranges: true,
});
attachComments(ast, comments, token);
} catch (e) {
return { parsedObject: result, success: false };
}
const parsedObjectProperties = new Set<TParsedJSProperty>();
let JSObjectProperties: NodeWithLocation<PropertyNode>[] = [];
simple(ast, {
VariableDeclarator(node: Node) {
ExportDefaultDeclaration(node, ancestors: Node[]) {
if (
isVariableDeclarator(node) &&
node.id.name === jsObjectVariableName &&
node.init &&
isObjectExpression(node.init)
) {
JSObjectProperties = node.init.properties;
}
!isExportDefaultDeclarationNode(node) ||
!isObjectExpression(node.declaration)
)
return;
JSObjectProperties = node.declaration
.properties as NodeWithLocation<PropertyNode>[];
},
});
JSObjectProperties.forEach((node) => {
let params = new Set<functionParam>();
const propertyNode = node;
let property: JsObjectProperty = {
key: generate(propertyNode.key),
value: generate(propertyNode.value),
type: propertyNode.value.type,
const propertyKey = node.key as NodeWithLocation<
LiteralNode | IdentifierNode
>;
let property: TParsedJSProperty = {
key: generate(node.key),
value: generate(node.value),
rawContent: extractContentByPosition(code, {
from: {
line: node.loc.start.line - 1,
ch: node.loc.start.column,
},
to: {
line: node.loc.end.line - 1,
ch: node.loc.end.column - 1,
},
}),
type: node.value.type,
position: {
startLine: node.loc.start.line,
startColumn: node.loc.start.column,
endLine: node.loc.end.line,
endColumn: node.loc.end.column,
keyStartLine: propertyKey.loc.start.line,
keyEndLine: propertyKey.loc.end.line,
keyStartColumn: propertyKey.loc.start.column,
keyEndColumn: propertyKey.loc.end.column,
},
};
if (isPropertyAFunctionNode(propertyNode.value)) {
if (isPropertyAFunctionNode(node.value)) {
// if in future we need default values of each param, we could implement that in getFunctionalParamsFromNode
// currently we don't consume it anywhere hence avoiding to calculate that.
params = getFunctionalParamsFromNode(propertyNode.value);
const params = getFunctionalParamsFromNode(node.value);
property = {
...property,
arguments: [...params],
isMarkedAsync: node.value.async,
};
}
// here we use `generate` function to convert our AST Node to JSCode
parsedObjectProperties.add(property);
});
return [...parsedObjectProperties];
return { parsedObject: [...parsedObjectProperties], success: true };
};

View File

@ -1,4 +1,5 @@
import unescapeJS from 'unescape-js';
import unescapeJS from "unescape-js";
import { isLiteralNode, PropertyNode } from "../index";
const beginsWithLineBreakRegex = /^\s+|\s+$/;
@ -8,14 +9,51 @@ export function sanitizeScript(js: string, evaluationVersion: number) {
// so that eval can happen
//default value of evalutaion version is 2
evaluationVersion = evaluationVersion ? evaluationVersion : 2;
const trimmedJS = js.replace(beginsWithLineBreakRegex, '');
const trimmedJS = js.replace(beginsWithLineBreakRegex, "");
return evaluationVersion > 1 ? trimmedJS : unescapeJS(trimmedJS);
}
// For the times when you need to know if something truly an object like { a: 1, b: 2}
// typeof, lodash.isObject and others will return false positives for things like array, null, etc
export const isTrueObject = (
item: unknown
item: unknown,
): item is Record<string, unknown> => {
return Object.prototype.toString.call(item) === '[object Object]';
return Object.prototype.toString.call(item) === "[object Object]";
};
export const getNameFromPropertyNode = (node: PropertyNode): string =>
isLiteralNode(node.key) ? String(node.key.value) : node.key.name;
type Position = {
line: number;
ch: number;
};
export const extractContentByPosition = (
content: string,
position: { from: Position; to: Position },
) => {
const eachLine = content.split("\n");
let returnedString = "";
for (let i = position.from.line; i <= position.to.line; i++) {
if (i === position.from.line) {
returnedString =
position.from.line !== position.to.line
? eachLine[position.from.line].slice(position.from.ch)
: eachLine[position.from.line].slice(
position.from.ch,
position.to.ch + 1,
);
} else if (i === position.to.line) {
returnedString += eachLine[position.to.line].slice(0, position.to.ch + 1);
} else {
returnedString += eachLine[i];
}
if (i !== position.to.line) {
returnedString += "\n";
}
}
return returnedString;
};

File diff suppressed because it is too large Load Diff