diff --git a/src/entities/argument/lib/__tests__/caseArgsContentSignature.test.ts b/src/entities/argument/lib/__tests__/caseArgsContentSignature.test.ts index ee47a5c5..7f984eb0 100644 --- a/src/entities/argument/lib/__tests__/caseArgsContentSignature.test.ts +++ b/src/entities/argument/lib/__tests__/caseArgsContentSignature.test.ts @@ -51,6 +51,9 @@ describe("buildCaseArgsContentSignature", () => { expect( buildCaseArgsContentSignature("p", "c", [{ ...argument, input: "[1]" }]), ).not.toBe(base); + expect( + buildCaseArgsContentSignature("p", "c", [{ ...argument, label: "root" }]), + ).not.toBe(base); }); it("changes when parentName changes", () => { diff --git a/src/entities/argument/lib/__tests__/getArgumentDisplayLabel.test.ts b/src/entities/argument/lib/__tests__/getArgumentDisplayLabel.test.ts new file mode 100644 index 00000000..b9919175 --- /dev/null +++ b/src/entities/argument/lib/__tests__/getArgumentDisplayLabel.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { getArgumentDisplayLabel } from "#/entities/argument/lib/getArgumentDisplayLabel"; +import { ArgumentType } from "#/entities/argument/model/argumentObject"; +import type { ArgumentObject } from "#/entities/argument/model/types"; + +const makeArg = ( + overrides: Partial & Pick, +): ArgumentObject => ({ + name: "uuid-key", + type: ArgumentType.ARRAY, + input: "[]", + ...overrides, +}); + +describe("getArgumentDisplayLabel", () => { + it("returns trimmed custom label when set", () => { + expect( + getArgumentDisplayLabel( + makeArg({ order: 0, label: " head " }), + ), + ).toBe("head"); + }); + + it("falls back to arg-N when label is missing or blank", () => { + expect(getArgumentDisplayLabel(makeArg({ order: 0 }))).toBe("arg-1"); + expect(getArgumentDisplayLabel(makeArg({ order: 2 }))).toBe("arg-3"); + expect(getArgumentDisplayLabel(makeArg({ order: 0, label: "" }))).toBe( + "arg-1", + ); + expect(getArgumentDisplayLabel(makeArg({ order: 0, label: " " }))).toBe( + "arg-1", + ); + }); + + it("allows duplicate labels across arguments (store keys stay unique)", () => { + const first = makeArg({ order: 0, name: "a", label: "nums" }); + const second = makeArg({ order: 1, name: "b", label: "nums" }); + expect(getArgumentDisplayLabel(first)).toBe("nums"); + expect(getArgumentDisplayLabel(second)).toBe("nums"); + expect(first.name).not.toBe(second.name); + }); +}); diff --git a/src/entities/argument/lib/argumentObjectValidator.ts b/src/entities/argument/lib/argumentObjectValidator.ts index fae35dfe..1cfc41b6 100644 --- a/src/entities/argument/lib/argumentObjectValidator.ts +++ b/src/entities/argument/lib/argumentObjectValidator.ts @@ -4,6 +4,7 @@ import { ArgumentType } from "../model/argumentObject"; export const argumentObjectValidator = z.object({ name: z.string(), + label: z.string().optional(), type: z.nativeEnum(ArgumentType), order: z.number(), input: z.string(), diff --git a/src/entities/argument/lib/caseArgsContentSignature.ts b/src/entities/argument/lib/caseArgsContentSignature.ts index 120f5d09..697899b7 100644 --- a/src/entities/argument/lib/caseArgsContentSignature.ts +++ b/src/entities/argument/lib/caseArgsContentSignature.ts @@ -14,6 +14,7 @@ export const buildCaseArgsContentSignature = ( const normalizedArguments = [...argumentsList] .map((argument) => ({ name: argument.name, + label: argument.label ?? null, parentName: argument.parentName ?? null, order: argument.order, type: argument.type, diff --git a/src/entities/argument/lib/getArgumentDisplayLabel.ts b/src/entities/argument/lib/getArgumentDisplayLabel.ts new file mode 100644 index 00000000..4ef28444 --- /dev/null +++ b/src/entities/argument/lib/getArgumentDisplayLabel.ts @@ -0,0 +1,11 @@ +import type { ArgumentObject } from "#/entities/argument/model/types"; + +/** + * User-visible argument name. Multiple arguments may share the same label; + * `ArgumentObject.name` remains the unique store key. + */ +export const getArgumentDisplayLabel = (arg: ArgumentObject): string => { + const trimmed = arg.label?.trim(); + if (trimmed) return trimmed; + return `arg-${arg.order + 1}`; +}; diff --git a/src/entities/argument/lib/index.ts b/src/entities/argument/lib/index.ts index 14f39eb0..04bc84e7 100644 --- a/src/entities/argument/lib/index.ts +++ b/src/entities/argument/lib/index.ts @@ -3,3 +3,4 @@ export { buildCaseArgsContentSignature } from "./caseArgsContentSignature"; export { isArgumentArrayType } from "./isArgumentArrayType"; export { isArgumentTreeType } from "./isArgumentTreeType"; export { isArgumentObjectValid } from "./isArgumentObjectValid"; +export { getArgumentDisplayLabel } from "./getArgumentDisplayLabel"; diff --git a/src/entities/argument/model/__tests__/caseSlice.test.ts b/src/entities/argument/model/__tests__/caseSlice.test.ts index 3cc831cf..a57d93b1 100644 --- a/src/entities/argument/model/__tests__/caseSlice.test.ts +++ b/src/entities/argument/model/__tests__/caseSlice.test.ts @@ -5,6 +5,7 @@ import { ArgumentType } from "#/entities/argument/model/argumentObject"; import { caseSlice, selectCaseArguments, + selectCaseIsEdited, } from "#/entities/argument/model/caseSlice"; import { callstackSlice } from "#/features/callstack/model/callstackSlice"; import { rootReducer } from "#/store/rootReducer"; @@ -76,4 +77,40 @@ describe("caseSlice selectors", () => { expect(secondArgs).not.toBe(firstArgs); expect(secondArgs).toEqual(firstArgs); }); + + it("updateArgumentLabel trims and clears blank labels", () => { + const store = configureStore({ reducer: rootReducer }); + + store.dispatch( + caseSlice.actions.setArguments({ + projectId: "project-1", + caseId: "case-1", + data: [ + { + name: "arg-id", + order: 0, + type: ArgumentType.ARRAY, + input: "[1]", + }, + ], + }), + ); + + store.dispatch( + caseSlice.actions.updateArgumentLabel({ + name: "arg-id", + label: " nums ", + }), + ); + expect(selectCaseArguments(store.getState())[0]?.label).toBe("nums"); + + store.dispatch( + caseSlice.actions.updateArgumentLabel({ + name: "arg-id", + label: " ", + }), + ); + expect(selectCaseArguments(store.getState())[0]?.label).toBeUndefined(); + expect(selectCaseIsEdited(store.getState())).toBe(true); + }); }); diff --git a/src/entities/argument/model/caseSlice.ts b/src/entities/argument/model/caseSlice.ts index 7bb77e1e..99047b3e 100644 --- a/src/entities/argument/model/caseSlice.ts +++ b/src/entities/argument/model/caseSlice.ts @@ -62,6 +62,20 @@ export const caseSlice = createSlice({ isParsed: false, }; }, + updateArgumentLabel: ( + state, + action: PayloadAction<{ name: string; label: string | undefined }>, + ) => { + const { name, label } = action.payload; + if (!state.args.entities[name]) return; + const nextLabel = + label === undefined || label.trim() === "" ? undefined : label.trim(); + argumentAdapter.updateOne(state.args, { + id: name, + changes: { label: nextLabel }, + }); + state.isEdited = true; + }, updateNodeData: ( state, action: PayloadAction>, diff --git a/src/entities/argument/model/types.ts b/src/entities/argument/model/types.ts index ce7ad2f4..39015df5 100644 --- a/src/entities/argument/model/types.ts +++ b/src/entities/argument/model/types.ts @@ -14,7 +14,10 @@ export type ArgumentArrayType = | ArgumentType.OBJECT; type BaseArgumentData = { + /** Stable unique id (Redux entity id, structure store key). */ name: string; + /** Optional display name; duplicates allowed. Falls back to `arg-${order + 1}`. */ + label?: string; parentName?: string; order: number; input: string; diff --git a/src/entities/dataStructures/array/model/__tests__/arraySlice.displayLabel.test.ts b/src/entities/dataStructures/array/model/__tests__/arraySlice.displayLabel.test.ts new file mode 100644 index 00000000..0f1c95f1 --- /dev/null +++ b/src/entities/dataStructures/array/model/__tests__/arraySlice.displayLabel.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { ArgumentType } from "#/entities/argument/model/argumentObject"; +import { arrayStructureSlice } from "#/entities/dataStructures/array/model/arraySlice"; + +describe("arrayStructureSlice.create displayLabel", () => { + it("persists displayLabel from runtime addArray options", () => { + const state = arrayStructureSlice.reducer( + {}, + arrayStructureSlice.actions.create({ + name: "runtime-array", + data: { + argType: ArgumentType.ARRAY, + options: { displayLabel: "array" }, + }, + }), + ); + + expect(state["runtime-array"]?.displayLabel).toBe("array"); + expect(state["runtime-array"]?.isRuntime).toBe(true); + }); + + it("omits displayLabel when options are absent", () => { + const state = arrayStructureSlice.reducer( + {}, + arrayStructureSlice.actions.create({ + name: "runtime-array", + data: { argType: ArgumentType.ARRAY }, + }), + ); + + expect(state["runtime-array"]?.displayLabel).toBeUndefined(); + }); +}); diff --git a/src/entities/dataStructures/array/model/arraySlice.ts b/src/entities/dataStructures/array/model/arraySlice.ts index fd2adccc..5cb3e183 100644 --- a/src/entities/dataStructures/array/model/arraySlice.ts +++ b/src/entities/dataStructures/array/model/arraySlice.ts @@ -31,6 +31,8 @@ export type ArrayData = BaseStructureItem & { childNames?: string[]; colHeaders?: string[]; rowHeaders?: string[]; + /** Inferred from user code (e.g. `const nums = [...]`); shown above the structure in the viewer. */ + displayLabel?: string; }; export type ArrayDataState = BaseStructureState; @@ -44,12 +46,14 @@ const getInitialData = ( argType: ArgumentArrayType, parentName?: string, childNames?: string[], + displayLabel?: string, ): ArrayData => ({ ...getInitialDataBase(arrayDataAdapter), order, argType, parentName, childNames, + displayLabel, }); const initialState: ArrayDataState = {}; @@ -74,10 +78,18 @@ export const arrayStructureSlice = createSlice({ childNames?: string[]; order: number; argType: ArgumentArrayType; + displayLabel?: string; }>, ) => { - const { name, order, argType, parentName, childNames } = action.payload; - state[name] = getInitialData(order, argType, parentName, childNames); + const { name, order, argType, parentName, childNames, displayLabel } = + action.payload; + state[name] = getInitialData( + order, + argType, + parentName, + childNames, + displayLabel, + ); }, create: ( state, @@ -93,7 +105,7 @@ export const arrayStructureSlice = createSlice({ data: { argType, nodes, options }, }, } = action; - const { colorMap } = options ?? {}; + const { colorMap, displayLabel } = options ?? {}; const treeState = { ...getInitialData(999, argType), isRuntime: true, @@ -106,6 +118,10 @@ export const arrayStructureSlice = createSlice({ treeState.colorMap = colorMap; } + if (displayLabel !== undefined) { + treeState.displayLabel = displayLabel; + } + state[name] = treeState; }, delete: (state, action: NamedPayload) => { diff --git a/src/entities/dataStructures/array/model/arrayStructure.ts b/src/entities/dataStructures/array/model/arrayStructure.ts index dc8a38ca..05a07bc7 100644 --- a/src/entities/dataStructures/array/model/arrayStructure.ts +++ b/src/entities/dataStructures/array/model/arrayStructure.ts @@ -19,6 +19,17 @@ const ArrayBase = makeArrayBaseClass(Array); export type ControlledArrayRuntimeOptions = { length?: number; colorMap?: Record; + /** User source variable name (inferred by AST); shown as a viewer label, not the internal tree id. */ + displayLabel?: string; +}; + +const RUNTIME_DISPLAY_LABEL_KEY = "__dstructRuntimeDisplayLabel"; + +const getRuntimeDisplayLabel = ( + array: ArrayBaseType, +): string | undefined => { + const label = Reflect.get(array, RUNTIME_DISPLAY_LABEL_KEY); + return typeof label === "string" ? label : undefined; }; export function initControlledArray( @@ -51,6 +62,14 @@ export function initControlledArray( value: callstack, enumerable: false, }, + ...(options?.displayLabel !== undefined + ? { + [RUNTIME_DISPLAY_LABEL_KEY]: { + value: options.displayLabel, + enumerable: false, + }, + } + : {}), }); if (addToCallstack) { @@ -184,13 +203,21 @@ export class ControlledArray extends ArrayBase { new Array(this.length), undefined, ); + const inheritedDisplayLabel = getRuntimeDisplayLabel(this); + const mapOptions: ControlledArrayRuntimeOptions | undefined = + inheritedDisplayLabel !== undefined || options !== undefined + ? { + ...options, + displayLabel: options?.displayLabel ?? inheritedDisplayLabel, + } + : undefined; const newArray = new ControlledArray( array as U[], id, data, this.callstack, true, - options, + mapOptions, ); for (let i = 0; i < this.length; i++) { newArray[i] = callback(this[i]!, i, this); @@ -269,22 +296,44 @@ export const getRuntimeArrayClass = (callstack: CallstackHelper) => class ArrayProxy extends ControlledArray { constructor(arrayLength: number); constructor(...items: Array) { - if (items.length === 1 && typeof items[0] === "number") { - const arrayLength = items[0]; - items = new Array(arrayLength); + let runtimeOptions: ControlledArrayRuntimeOptions | undefined; + let elementItems = items as Array; + const lastItem = elementItems.at(-1); + if ( + elementItems.length >= 2 && + lastItem !== null && + lastItem !== undefined && + typeof lastItem === "object" && + !Array.isArray(lastItem) && + "displayLabel" in lastItem + ) { + runtimeOptions = lastItem as ControlledArrayRuntimeOptions; + elementItems = elementItems.slice(0, -1) as Array; + } + + if (elementItems.length === 1 && typeof elementItems[0] === "number") { + const arrayLength = elementItems[0]; + elementItems = new Array(arrayLength) as Array; } if ( - items[0] && - typeof items[0] !== "number" && - typeof items[0] !== "string" + elementItems[0] && + typeof elementItems[0] !== "number" && + typeof elementItems[0] !== "string" ) { throw new Error("ArrayProxy can only contain numbers or strings"); } - const data = generateArrayData(items); + const data = generateArrayData(elementItems as T[]); - super(items, generate(), data, callstack, true); + super( + elementItems as T[], + generate(), + data, + callstack, + true, + runtimeOptions, + ); } static override from( diff --git a/src/entities/dataStructures/array/ui/ArrayStructureView.tsx b/src/entities/dataStructures/array/ui/ArrayStructureView.tsx index cc1bfc2b..18c9654c 100644 --- a/src/entities/dataStructures/array/ui/ArrayStructureView.tsx +++ b/src/entities/dataStructures/array/ui/ArrayStructureView.tsx @@ -8,6 +8,7 @@ import { } from "#/entities/dataStructures/array/model/arraySlice"; import { ArrayItem } from "./ArrayItem"; +import { StructureDisplayLabel } from "./StructureDisplayLabel"; type ArrayBracketProps = { argType: ArgumentType; @@ -81,46 +82,48 @@ export const ArrayStructureView: React.FC = ({ return arrayDataItemSelectors.selectAll(data.nodes); }, [data.nodes]); - const { argType } = data; + const { argType, displayLabel } = data; return ( - + {displayLabel ? : null} + - - - {items.length > 0 ? ( - items.map((item) => ( - + + + {items.length > 0 ? ( + items.map((item) => ( + + )) + ) : ( + - )) - ) : ( - - )} + )} + + - ); }; diff --git a/src/entities/dataStructures/array/ui/StructureDisplayLabel.tsx b/src/entities/dataStructures/array/ui/StructureDisplayLabel.tsx new file mode 100644 index 00000000..3c282590 --- /dev/null +++ b/src/entities/dataStructures/array/ui/StructureDisplayLabel.tsx @@ -0,0 +1,24 @@ +import { Typography } from "@mui/material"; +import React from "react"; + +type StructureDisplayLabelProps = { + label: string; +}; + +/** Small caption above a runtime structure when we inferred a variable name from user code. */ +export const StructureDisplayLabel: React.FC = ({ + label, +}) => ( + + {label} + +); diff --git a/src/entities/dataStructures/map/ui/MapStructureView.tsx b/src/entities/dataStructures/map/ui/MapStructureView.tsx index 1d4bcc08..b1e1b182 100644 --- a/src/entities/dataStructures/map/ui/MapStructureView.tsx +++ b/src/entities/dataStructures/map/ui/MapStructureView.tsx @@ -3,6 +3,7 @@ import React, { useMemo } from "react"; import { arrayDataItemSelectors } from "#/entities/dataStructures/array/model/arraySlice"; import { type ArrayStructureViewProps } from "#/entities/dataStructures/array/ui/ArrayStructureView"; +import { StructureDisplayLabel } from "#/entities/dataStructures/array/ui/StructureDisplayLabel"; import { MapItem } from "#/entities/dataStructures/map/ui/MapItem"; type MapStructureViewProps = ArrayStructureViewProps; @@ -18,64 +19,68 @@ export const MapStructureView: React.FC = ({ [data.nodes], ); - return ( - td:first-of-type": { - borderTopLeftRadius: "4px", - }, - "& > td:last-child": { - borderTopRightRadius: "4px", - }, - "&::after": { - borderRadius: "4px 4px 0 0", - }, - }, - "& tr:last-child": { - "& > td:first-of-type": { - borderBottomLeftRadius: "4px", + return ( + + {displayLabel ? : null} + td:last-child": { - borderBottomRightRadius: "4px", + + "& tr:first-of-type": { + "& > td:first-of-type": { + borderTopLeftRadius: "4px", + }, + "& > td:last-child": { + borderTopRightRadius: "4px", + }, + "&::after": { + borderRadius: "4px 4px 0 0", + }, }, - "&::after": { - borderRadius: "0 0 4px 4px", + "& tr:last-child": { + "& > td:first-of-type": { + borderBottomLeftRadius: "4px", + }, + "& > td:last-child": { + borderBottomRightRadius: "4px", + }, + "&::after": { + borderRadius: "0 0 4px 4px", + }, }, - }, - ...sx, - }} - > - - {items.map((item) => ( - - ))} - {items.length === 0 && ( - - + + {items.map((item) => ( + - - )} - + ))} + {items.length === 0 && ( + + + + )} + + ); }; diff --git a/src/entities/dataStructures/matrix/ui/MatrixStructureView.tsx b/src/entities/dataStructures/matrix/ui/MatrixStructureView.tsx index ba5ab1be..e06ec088 100644 --- a/src/entities/dataStructures/matrix/ui/MatrixStructureView.tsx +++ b/src/entities/dataStructures/matrix/ui/MatrixStructureView.tsx @@ -6,6 +6,7 @@ import type { ArrayDataState, ArrayItemData, } from "#/entities/dataStructures/array/model/arraySlice"; +import { StructureDisplayLabel } from "#/entities/dataStructures/array/ui/StructureDisplayLabel"; import { MatrixRow } from "#/entities/dataStructures/matrix/ui/MatrixRow"; type MatrixStructureViewProps = { @@ -36,6 +37,9 @@ export const MatrixStructureView: React.FC = ({ overflow: "hidden", }} > + {data.displayLabel ? ( + + ) : null} = ({ arg }) => { const dispatch = useAppDispatch(); + const inputLabel = `${getArgumentDisplayLabel(arg)} (${argumentTypeLabels[arg.type]})`; + const renameSuffix = argumentRenameEndAdornment(arg.name, arg.label ?? ""); const handleChange = (value: string) => { dispatch( @@ -51,37 +55,56 @@ export const ArgInput: React.FC<{ arg: ArgumentObject }> = ({ arg }) => { case ArgumentType.GRAPH: return ( ); case ArgumentType.STRING: return ( ); case ArgumentType.BOOLEAN: - return ; + return ( + + + + {inputLabel} + + {renameSuffix} + + + + ); } return ( handleChange(ev.target.value)} + slotProps={{ input: { endAdornment: renameSuffix } }} /> ); }; diff --git a/src/features/argsEditor/ui/ArgsEditor.tsx b/src/features/argsEditor/ui/ArgsEditor.tsx index 6a193ef8..ca5e6c6a 100644 --- a/src/features/argsEditor/ui/ArgsEditor.tsx +++ b/src/features/argsEditor/ui/ArgsEditor.tsx @@ -15,7 +15,10 @@ import { useSnackbar } from "notistack"; import React, { useEffect } from "react"; import { generate } from "short-uuid"; -import { isArgumentObjectValid } from "#/entities/argument/lib"; +import { + getArgumentDisplayLabel, + isArgumentObjectValid, +} from "#/entities/argument/lib"; import { ArgumentType } from "#/entities/argument/model/argumentObject"; import { caseSlice, @@ -242,7 +245,9 @@ export const ArgsEditor: React.FC = ({ selectedCase }) => { onChange={(type) => handleArgTypeChange(arg, type)} /> handleDeleteArg(arg)} size="small" sx={{ top: -2 }} diff --git a/src/features/argsEditor/ui/ArgumentRenameSuffix.tsx b/src/features/argsEditor/ui/ArgumentRenameSuffix.tsx new file mode 100644 index 00000000..beccf05d --- /dev/null +++ b/src/features/argsEditor/ui/ArgumentRenameSuffix.tsx @@ -0,0 +1,140 @@ +"use client"; + +import DriveFileRenameOutline from "@mui/icons-material/DriveFileRenameOutline"; +import { + IconButton, + InputAdornment, + Popover, + TextField, + Tooltip, + Typography, +} from "@mui/material"; +import React, { useCallback, useEffect, useId, useRef, useState } from "react"; + +import { caseSlice } from "#/entities/argument/model/caseSlice"; +import { useI18nContext } from "#/shared/hooks"; +import { useAppDispatch } from "#/store/hooks"; + +const SAVE_DEBOUNCE_MS = 300; + +type ArgumentRenameSuffixProps = { + argumentId: string; + labelDraft: string; +}; + +const normalizeLabel = (raw: string): string | undefined => { + const trimmed = raw.trim(); + return trimmed === "" ? undefined : trimmed; +}; + +export const ArgumentRenameSuffix: React.FC = ({ + argumentId, + labelDraft, +}) => { + const { LL } = useI18nContext(); + const dispatch = useAppDispatch(); + const titleId = useId(); + const [anchorEl, setAnchorEl] = useState(null); + const [localDraft, setLocalDraft] = useState(labelDraft); + const debounceRef = useRef | null>(null); + + const flushLabelToStore = useCallback( + (raw: string) => { + dispatch( + caseSlice.actions.updateArgumentLabel({ + name: argumentId, + label: normalizeLabel(raw), + }), + ); + }, + [argumentId, dispatch], + ); + + useEffect(() => { + if (!anchorEl) return; + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + debounceRef.current = setTimeout(() => { + debounceRef.current = null; + flushLabelToStore(localDraft); + }, SAVE_DEBOUNCE_MS); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + }; + }, [anchorEl, flushLabelToStore, localDraft]); + + const handleOpen = useCallback( + (event: React.MouseEvent) => { + setLocalDraft(labelDraft); + setAnchorEl(event.currentTarget); + }, + [labelDraft], + ); + + const handleClose = useCallback(() => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + debounceRef.current = null; + } + flushLabelToStore(localDraft); + setAnchorEl(null); + }, [flushLabelToStore, localDraft]); + + const open = Boolean(anchorEl); + + return ( + <> + + + + + + + + {LL.ARGUMENT_DISPLAY_NAME()} + + setLocalDraft(event.target.value)} + placeholder={LL.ARGUMENT_DISPLAY_NAME_PLACEHOLDER()} + helperText={LL.ARGUMENT_DISPLAY_NAME_HELPER()} + /> + + + ); +}; + +export const argumentRenameEndAdornment = ( + argumentId: string, + labelDraft: string, +): React.ReactNode => ( + + + +); diff --git a/src/features/codeRunner/lib/__tests__/arrayDisplayLabel.runtime.test.ts b/src/features/codeRunner/lib/__tests__/arrayDisplayLabel.runtime.test.ts new file mode 100644 index 00000000..d00b4603 --- /dev/null +++ b/src/features/codeRunner/lib/__tests__/arrayDisplayLabel.runtime.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, it } from "vitest"; + +import { ArgumentType } from "#/entities/argument/model/argumentObject"; +import type { ArgumentObject } from "#/entities/argument/model/types"; +import { BinaryTreeNode } from "#/entities/dataStructures/binaryTree/model/binaryTreeNode"; +import type { TreeData } from "#/entities/dataStructures/node/model/nodeSlice"; +import { CallstackHelper } from "#/features/callstack/model/callstackSlice"; +import { createCaseRuntimeArgs } from "#/features/codeRunner/lib/createCaseRuntimeArgs"; +import { instrumentUserJsForLineTracking } from "#/features/codeRunner/lib/instrumentUserJsForLineTracking"; +import { + globalDefinitionsPrefix, + setGlobalRuntimeContext, +} from "#/features/codeRunner/lib/setGlobalRuntimeContext"; + +type AddArrayFrame = { + name: "addArray"; + args: { options?: { displayLabel?: string } }; +}; + +const isAddArrayFrame = ( + frame: { name: string }, +): frame is AddArrayFrame => frame.name === "addArray"; + +const runInstrumentedSolution = ( + solutionBody: string, + invokeArgs: unknown[] = [], + paramList = "", +): { callstack: CallstackHelper; result: unknown } => { + const callstack = new CallstackHelper(); + setGlobalRuntimeContext(callstack); + + const code = `return function solve(${paramList}) {\n${solutionBody}\n};`; + const { code: instrumented } = instrumentUserJsForLineTracking(code); + const run = new Function(`${globalDefinitionsPrefix}\n${instrumented}`) as () => ( + ...args: unknown[] + ) => unknown; + + callstack.clear(); + const result = run()(...invokeArgs); + return { callstack, result }; +}; + +const addArrayFramesWithLabel = ( + callstack: CallstackHelper, + displayLabel: string, +) => + callstack.frames.filter( + (frame): frame is AddArrayFrame => + isAddArrayFrame(frame) && + frame.args.options?.displayLabel === displayLabel, + ); + +describe("array displayLabel runtime", () => { + it("records displayLabel when __dstructArrayLiteralWithName runs", () => { + const { callstack } = runInstrumentedSolution(` + const nums = [1, 2, 3]; + return nums; + `); + + expect(addArrayFramesWithLabel(callstack, "nums")).toHaveLength(1); + }); + + it("inherits displayLabel on array.map result", () => { + const { callstack } = runInstrumentedSolution(` + const array = [1, 2]; + return array.map((value) => value * 10); + `); + + expect(addArrayFramesWithLabel(callstack, "array").length).toBeGreaterThan( + 1, + ); + }); + + it("does not emit addArray for plain [] when transform is bypassed", () => { + const callstack = new CallstackHelper(); + setGlobalRuntimeContext(callstack); + + const run = new Function( + `${globalDefinitionsPrefix} +return function solve() { + const array = []; + array.push(1); + return array; +};`, + ) as () => () => unknown; + + callstack.clear(); + run()(); + + expect(callstack.frames.some(isAddArrayFrame)).toBe(false); + }); + + it("tracks getLevels-style nested arrays when instrumented", () => { + const treeStore = buildBinaryTreeFixture(); + const caseArgs: ArgumentObject[] = [ + { + name: "head", + type: ArgumentType.BINARY_TREE, + order: 0, + input: "", + }, + ]; + const setupCallstack = new CallstackHelper(); + const head = createCaseRuntimeArgs( + setupCallstack, + treeStore, + {}, + caseArgs, + )[0] as BinaryTreeNode; + + const { callstack, result } = runInstrumentedSolution( + ` + const array = []; + const dfs = (node, depth = 0) => { + array[depth] ??= []; + array[depth].push(node.val); + node.setColor("green"); + if (node.left) dfs(node.left, depth + 1); + if (node.right) dfs(node.right, depth + 1); + }; + dfs(head); + return array.map((values) => + values.reduce((sum, value) => sum + value, 0) / values.length, + ); + `, + [head], + "head", + ); + + expect(Array.isArray(result)).toBe(true); + expect((result as unknown[]).length).toBeGreaterThan(0); + expect(addArrayFramesWithLabel(callstack, "array").length).toBeGreaterThan( + 0, + ); + expect(callstack.frames.some((frame) => frame.name === "addArrayItem")).toBe( + true, + ); + }); +}); + +function buildBinaryTreeFixture(): { head: TreeData } { + const rootId = "root"; + const leftId = "left"; + const rightId = "right"; + + return { + head: { + type: ArgumentType.BINARY_TREE, + order: 0, + maxDepth: 1, + rootId, + edges: { ids: [], entities: {} }, + initialEdges: null, + hiddenNodes: { ids: [], entities: {} }, + initialNodes: null, + isRuntime: false, + nodes: { + ids: [rootId, leftId, rightId], + entities: { + [rootId]: { + id: rootId, + value: 1, + depth: 0, + argType: ArgumentType.BINARY_TREE, + childrenIds: [leftId, rightId], + x: 0, + y: 0, + }, + [leftId]: { + id: leftId, + value: 3, + depth: 1, + argType: ArgumentType.BINARY_TREE, + childrenIds: ["", ""], + x: 0, + y: 0, + }, + [rightId]: { + id: rightId, + value: 2, + depth: 1, + argType: ArgumentType.BINARY_TREE, + childrenIds: ["", ""], + x: 0, + y: 0, + }, + }, + }, + }, + }; +} diff --git a/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts b/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts index 7981071b..7909e094 100644 --- a/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts +++ b/src/features/codeRunner/lib/__tests__/instrumentUserJsForLineTracking.test.ts @@ -3,36 +3,28 @@ import { describe, expect, it } from "vitest"; import { instrumentUserJsForLineTracking } from "#/features/codeRunner/lib/instrumentUserJsForLineTracking"; describe("instrumentUserJsForLineTracking", () => { - it("injects globalThis.__dstructSetExecutionSource before statements in returned function", () => { + it("injects line probes before statements in the returned solution function", () => { const code = `return function sum(a) { return a + 1; };`; const { code: out, ok } = instrumentUserJsForLineTracking(code); expect(ok).toBe(true); - expect(out).toContain("__dstructSetExecutionSource"); expect(out).toContain("globalThis.__dstructSetExecutionSource"); }); - it("instruments nested function bodies", () => { - const code = `return function outer() { - function inner() { - return 1; - } - return inner(); -};`; - const { ok } = instrumentUserJsForLineTracking(code); - expect(ok).toBe(true); - }); - - it("skips instrumentation when there is no return function template", () => { - const code = `function run() { return 1; } + it("still transforms array literals when line probes cannot run", () => { + const code = `function run() { + const a = [1, 2]; + return a; +} return run;`; const { code: out, ok } = instrumentUserJsForLineTracking(code); expect(ok).toBe(false); - expect(out).toBe(code); + expect(out).toContain('__dstructArrayLiteralWithName("a", 1, 2)'); + expect(out).not.toContain("__dstructSetExecutionSource"); }); - it("does not instrument top-level functions outside the returned solution", () => { + it("does not instrument top-level helpers outside the returned solution", () => { const code = `function helper() { return 1; } @@ -53,24 +45,4 @@ return function main() { expect(mainIdx).toBeGreaterThan(helperIdx); expect(firstProbeAfterHelper).toBeGreaterThan(mainIdx); }); - - it("instruments return arrow function with block body", () => { - const code = `return () => { - const n = 1; - return n; -};`; - const { code: out, ok } = instrumentUserJsForLineTracking(code); - expect(ok).toBe(true); - expect(out).toContain("globalThis.__dstructSetExecutionSource"); - }); - - it("replaces array literals with __dstructArrayLiteral in solution", () => { - const code = `return function f() { - const a = [1, 2]; - return a; -};`; - const { code: out, ok } = instrumentUserJsForLineTracking(code); - expect(ok).toBe(true); - expect(out).toContain("__dstructArrayLiteral(1, 2)"); - }); }); diff --git a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts index e8e33105..a2323337 100644 --- a/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts +++ b/src/features/codeRunner/lib/__tests__/transformJsArrayLiterals.test.ts @@ -9,7 +9,10 @@ import type { } from "@babel/types"; import { describe, expect, it } from "vitest"; -import { transformArrayLiteralsInSolution } from "#/features/codeRunner/lib/transformJsArrayLiterals"; +import { + transformArrayLiteralsInProgram, + transformArrayLiteralsInSolution, +} from "#/features/codeRunner/lib/transformJsArrayLiterals"; const findSolution = ( ast: File, @@ -30,10 +33,12 @@ const findSolution = ( }; describe("transformArrayLiteralsInSolution", () => { - it("replaces [] and [1, 2, 3] with __dstructArrayLiteral calls", () => { + it("uses __dstructArrayLiteralWithName when literal is RHS of const / assignment", () => { const code = `return function f() { const a = []; const b = [1, 2, 3]; + let c; + c = [4]; return b; };`; const ast = parse(code, { @@ -44,8 +49,25 @@ describe("transformArrayLiteralsInSolution", () => { expect(solution).toBeTruthy(); transformArrayLiteralsInSolution(solution!); const out = generate(ast).code; - expect(out).toContain("__dstructArrayLiteral()"); - expect(out).toContain("__dstructArrayLiteral(1, 2, 3)"); + expect(out).toContain('__dstructArrayLiteralWithName("a")'); + expect(out).toContain('__dstructArrayLiteralWithName("b", 1, 2, 3)'); + expect(out).toContain('__dstructArrayLiteralWithName("c", 4)'); + }); + + it("uses unnamed helper when only element is a string literal (avoid ambiguity)", () => { + const code = `return function f() { + const labels = ["one"]; + return labels; +};`; + const ast = parse(code, { + sourceType: "unambiguous", + allowReturnOutsideFunction: true, + }); + const solution = findSolution(ast); + transformArrayLiteralsInSolution(solution!); + const out = generate(ast).code; + expect(out).toContain('__dstructArrayLiteral("one")'); + expect(out).not.toContain("__dstructArrayLiteralWithName"); }); it("preserves spread elements", () => { @@ -62,6 +84,60 @@ describe("transformArrayLiteralsInSolution", () => { expect(out).toContain("__dstructArrayLiteral(...arr, 4)"); }); + it("appends displayLabel object to new Array / new ArrayProxy when binding is inferable", () => { + const code = `return function f() { + const nums = new Array(1, 2, 3); + const copy = new ArrayProxy(4, 5); + return nums; +};`; + const ast = parse(code, { + sourceType: "unambiguous", + allowReturnOutsideFunction: true, + }); + const solution = findSolution(ast); + transformArrayLiteralsInSolution(solution!); + const out = generate(ast).code; + expect(out).toMatch( + /new Array\(1,\s*2,\s*3,\s*\{[^}]*displayLabel:\s*"nums"/, + ); + expect(out).toMatch( + /new ArrayProxy\(4,\s*5,\s*\{[^}]*displayLabel:\s*"copy"/, + ); + }); + + it("names array literals on ??= assignment to an identifier", () => { + const code = `return function f() { + let bucket; + bucket ??= []; + return bucket; +};`; + const ast = parse(code, { + sourceType: "unambiguous", + allowReturnOutsideFunction: true, + }); + const solution = findSolution(ast); + expect(solution).toBeTruthy(); + transformArrayLiteralsInSolution(solution!); + const out = generate(ast).code; + expect(out).toContain('bucket ??= __dstructArrayLiteralWithName("bucket"'); + }); + + it("does not append displayLabel to ambiguous new Array(number) length form", () => { + const code = `return function f() { + const buf = new Array(10); + return buf; +};`; + const ast = parse(code, { + sourceType: "unambiguous", + allowReturnOutsideFunction: true, + }); + const solution = findSolution(ast); + transformArrayLiteralsInSolution(solution!); + const out = generate(ast).code; + expect(out).toContain("new Array(10)"); + expect(out).not.toContain("displayLabel"); + }); + it("does not replace array used as computed object key", () => { const code = `return function f() { const o = { [[1, 2]]: 0 }; @@ -78,3 +154,19 @@ describe("transformArrayLiteralsInSolution", () => { expect(out).not.toContain("__dstructArrayLiteral(1, 2)"); }); }); + +describe("transformArrayLiteralsInProgram", () => { + it("rewrites literals in any function when solution template is absent", () => { + const code = `function run() { + const rows = [1, 2]; + return rows; +}`; + const ast = parse(code, { + sourceType: "unambiguous", + allowReturnOutsideFunction: true, + }); + transformArrayLiteralsInProgram(ast); + const out = generate(ast).code; + expect(out).toContain('__dstructArrayLiteralWithName("rows", 1, 2)'); + }); +}); diff --git a/src/features/codeRunner/lib/instrumentUserJsForLineTracking.ts b/src/features/codeRunner/lib/instrumentUserJsForLineTracking.ts index 16caae1a..d490aa9c 100644 --- a/src/features/codeRunner/lib/instrumentUserJsForLineTracking.ts +++ b/src/features/codeRunner/lib/instrumentUserJsForLineTracking.ts @@ -4,7 +4,10 @@ import traverse, { type NodePath } from "@babel/traverse"; import * as babelTypes from "@babel/types"; import type { File } from "@babel/types"; -import { transformArrayLiteralsInSolution } from "#/features/codeRunner/lib/transformJsArrayLiterals"; +import { + transformArrayLiteralsInProgram, + transformArrayLiteralsInSolution, +} from "#/features/codeRunner/lib/transformJsArrayLiterals"; const insertProbesInBlock = (bodyPath: NodePath) => { const statements = bodyPath.get("body"); @@ -61,9 +64,8 @@ const findReturnedSolutionPath = (file: File): SolutionFnPath | null => { }; /** - * Injects `globalThis.__dstructSetExecutionSource(line, column)` before each statement - * in every function body **inside** the first `return ` (dStruct solution template). - * Line/column match the Monaco model (user code only). + * Rewrites user JS for the code runner: array literals → tracked helpers (always when parse + * succeeds), and optionally line probes for editor sync (`ok` reflects probes only). */ export const instrumentUserJsForLineTracking = ( code: string, @@ -76,12 +78,14 @@ export const instrumentUserJsForLineTracking = ( }); const solutionPath = findReturnedSolutionPath(ast); - if (!solutionPath) { - return { code, ok: false }; + if (solutionPath) { + transformArrayLiteralsInSolution(solutionPath); + } else { + transformArrayLiteralsInProgram(ast); + const out = generate(ast, { retainLines: false }); + return { code: out.code, ok: false }; } - transformArrayLiteralsInSolution(solutionPath); - // traverse() does not visit the root path; instrument the solution fn itself first. instrumentFunctionBody(solutionPath as NodePath); diff --git a/src/features/codeRunner/lib/setGlobalRuntimeContext.ts b/src/features/codeRunner/lib/setGlobalRuntimeContext.ts index 0f786898..fdecc0b1 100644 --- a/src/features/codeRunner/lib/setGlobalRuntimeContext.ts +++ b/src/features/codeRunner/lib/setGlobalRuntimeContext.ts @@ -1,7 +1,11 @@ import { PriorityQueue } from "@datastructures-js/priority-queue"; import { generate } from "short-uuid"; -import { getRuntimeArrayClass } from "#/entities/dataStructures/array/model/arrayStructure"; +import { generateArrayData } from "#/entities/dataStructures/array/lib/generateArrayData"; +import { + ControlledArray, + getRuntimeArrayClass, +} from "#/entities/dataStructures/array/model/arrayStructure"; import { getRuntimeSet } from "#/entities/dataStructures/array/model/setStructure"; import { getRuntimeString } from "#/entities/dataStructures/array/model/stringStructure"; import { @@ -57,21 +61,51 @@ export const setGlobalRuntimeContext = (callstack: CallstackHelper) => { const ObjectProxy = getRuntimeObject(callstack); + const buildTrackedArrayFromLiteralElements = ( + elements: unknown[], + displayLabel?: string, + ) => { + if (displayLabel !== undefined) { + const values: unknown[] = []; + for (let index = 0; index < elements.length; index += 1) { + values[index] = elements[index]; + } + const data = generateArrayData(values); + return new ControlledArray( + values as (string | number)[], + generate(), + data, + callstack, + true, + { displayLabel }, + ); + } + + const out = new ArrayProxy(0) as unknown[]; + for (let index = 0; index < elements.length; index += 1) { + out[index] = elements[index]; + } + return out; + }; + const context = { __dstructSetExecutionSource: (line: number, column?: number) => { setExecutionSource(line, column ?? 0); }, /** * Build a tracked array from literal elements (avoid `new Array(n)` length ambiguity - * in ArrayProxy). Used by AST transform for `[]` and `[1,2,3]`. + * in ArrayProxy). Used by AST transform for `[]` and `[1,2,3]` when the binding name + * is unknown or would be ambiguous (e.g. single string element). */ - __dstructArrayLiteral: (...elements: unknown[]) => { - const out = new ArrayProxy(0) as unknown[]; - for (let i = 0; i < elements.length; i += 1) { - out[i] = elements[i]; - } - return out; - }, + __dstructArrayLiteral: (...elements: unknown[]) => + buildTrackedArrayFromLiteralElements(elements), + /** + * Same as `__dstructArrayLiteral` but records `displayLabel` for the viewer (inferred variable name). + */ + __dstructArrayLiteralWithName: ( + displayLabel: string, + ...elements: unknown[] + ) => buildTrackedArrayFromLiteralElements(elements, displayLabel), ArrayProxy, Uint32ArrayProxy, Int32ArrayProxy, @@ -115,6 +149,7 @@ export const setGlobalRuntimeContext = (callstack: CallstackHelper) => { export const globalDefinitionsPrefix = ` const console = {...self.console, log: self.log, error: self.error, warn: self.warn, info: self.info}; const __dstructArrayLiteral = self.__dstructArrayLiteral; + const __dstructArrayLiteralWithName = self.__dstructArrayLiteralWithName; const Array = self.ArrayProxy; const Uint32Array = self.Uint32ArrayProxy; const Int32Array = self.Int32ArrayProxy; diff --git a/src/features/codeRunner/lib/transformJsArrayLiterals.ts b/src/features/codeRunner/lib/transformJsArrayLiterals.ts index 4b309709..e13e4465 100644 --- a/src/features/codeRunner/lib/transformJsArrayLiterals.ts +++ b/src/features/codeRunner/lib/transformJsArrayLiterals.ts @@ -1,17 +1,83 @@ -import { type NodePath } from "@babel/traverse"; +import traverse, { type NodePath } from "@babel/traverse"; +import type { File } from "@babel/types"; import * as babelTypes from "@babel/types"; +const ARRAY_LITERAL_HELPER = "__dstructArrayLiteral"; +const ARRAY_LITERAL_NAMED_HELPER = "__dstructArrayLiteralWithName"; + +type InferableRhsPath = + | NodePath + | NodePath; + +/** + * Single string literal as the only element is ambiguous with `__dstructArrayLiteralWithName("label", "x")`. + */ +const shouldUseUnnamedArrayLiteralHelper = ( + elements: ReadonlyArray< + babelTypes.Expression | babelTypes.SpreadElement | null + >, +): boolean => { + if (elements.length !== 1) return false; + const only = elements[0]; + if (only === null) return false; + return babelTypes.isStringLiteral(only); +}; + +const ASSIGNMENT_OPERATORS_WITH_BINDING = new Set(["=", "??=", "||=", "&&="]); + +const tryInferBindingNameFromRhsPath = ( + path: InferableRhsPath, +): string | null => { + const parent = path.parent; + const node = path.node; + if ( + babelTypes.isVariableDeclarator(parent) && + babelTypes.isIdentifier(parent.id) && + parent.init === node + ) { + return parent.id.name; + } + if ( + babelTypes.isAssignmentExpression(parent) && + ASSIGNMENT_OPERATORS_WITH_BINDING.has(parent.operator) && + babelTypes.isIdentifier(parent.left) && + parent.right === node + ) { + return parent.left.name; + } + if ( + babelTypes.isAssignmentPattern(parent) && + babelTypes.isIdentifier(parent.left) && + parent.right === node + ) { + return parent.left.name; + } + return null; +}; + +const isTrackedArrayCallee = ( + callee: babelTypes.Expression | babelTypes.V8IntrinsicIdentifier, +): boolean => { + if (babelTypes.isIdentifier(callee)) { + return callee.name === "Array" || callee.name === "ArrayProxy"; + } + return false; +}; + /** * Replace `[a, b, ...rest]` with `__dstructArrayLiteral(a, b, ...rest)` inside the solution * function so literals use the proxied `Array` (tracked), matching Python `TrackedList` behavior. + * When the literal is the RHS of a simple binding (`const x = [...]`, `x = [...]`, default param), + * uses `__dstructArrayLiteralWithName("x", ...)` so the viewer can show the variable name. + * + * Also appends `{ displayLabel: "x" }` to `new Array(...)` / `new ArrayProxy(...)` when the name + * is inferable so dynamic `new Array(1,2,3)` constructions get viewer labels (same as literals). * Runs before line probes so locations stay aligned with user source. */ -export const transformArrayLiteralsInSolution = ( - solutionPath: NodePath< - babelTypes.FunctionExpression | babelTypes.ArrowFunctionExpression - >, +export const transformArrayLiteralsInFunction = ( + functionPath: NodePath, ): void => { - solutionPath.traverse({ + functionPath.traverse({ ArrayExpression(path: NodePath) { const { node, parent } = path; @@ -34,11 +100,104 @@ export const transformArrayLiteralsInSolution = ( } } + const bindingName = tryInferBindingNameFromRhsPath(path); + const useNamed = + bindingName !== null && + !shouldUseUnnamedArrayLiteralHelper(node.elements); + path.replaceWith( - babelTypes.callExpression( - babelTypes.identifier("__dstructArrayLiteral"), - args, + useNamed + ? babelTypes.callExpression( + babelTypes.identifier(ARRAY_LITERAL_NAMED_HELPER), + [babelTypes.stringLiteral(bindingName), ...args], + ) + : babelTypes.callExpression( + babelTypes.identifier(ARRAY_LITERAL_HELPER), + args, + ), + ); + }, + + NewExpression(path: NodePath) { + const { node, parent } = path; + if (!isTrackedArrayCallee(node.callee)) return; + + if ( + babelTypes.isObjectProperty(parent) && + parent.computed && + parent.key === node + ) { + return; + } + + const lastArg = node.arguments.at(-1); + if ( + lastArg && + babelTypes.isExpression(lastArg) && + !babelTypes.isSpreadElement(lastArg) && + babelTypes.isObjectExpression(lastArg) + ) { + const props = lastArg.properties; + const onlyDisplayLabel = + props.length === 1 && + babelTypes.isObjectProperty(props[0]) && + !props[0].computed && + babelTypes.isIdentifier(props[0].key, { name: "displayLabel" }); + if (onlyDisplayLabel) { + return; + } + } + + const bindingName = tryInferBindingNameFromRhsPath(path); + if (bindingName === null) return; + + if (node.arguments.length === 1) { + const only = node.arguments[0]; + if ( + only && + babelTypes.isExpression(only) && + !babelTypes.isSpreadElement(only) && + babelTypes.isNumericLiteral(only) + ) { + return; + } + } + + const displayLabelObject = babelTypes.objectExpression([ + babelTypes.objectProperty( + babelTypes.identifier("displayLabel"), + babelTypes.stringLiteral(bindingName), + false, + true, ), + ]); + + path.replaceWith( + babelTypes.newExpression(node.callee, [ + ...node.arguments, + displayLabelObject, + ]), + ); + }, + }); +}; + +export const transformArrayLiteralsInSolution = ( + solutionPath: NodePath< + babelTypes.FunctionExpression | babelTypes.ArrowFunctionExpression + >, +): void => { + transformArrayLiteralsInFunction( + solutionPath as NodePath, + ); +}; + +/** Fallback when the solution template (`return function …`) is missing. */ +export const transformArrayLiteralsInProgram = (file: File): void => { + traverse(file, { + "FunctionDeclaration|FunctionExpression|ArrowFunctionExpression"(path) { + transformArrayLiteralsInFunction( + path as NodePath, ); }, }); diff --git a/src/features/codeRunner/lib/workers/codeExec.worker.ts b/src/features/codeRunner/lib/workers/codeExec.worker.ts index 38cf82f4..f939a90c 100644 --- a/src/features/codeRunner/lib/workers/codeExec.worker.ts +++ b/src/features/codeRunner/lib/workers/codeExec.worker.ts @@ -113,9 +113,7 @@ self.addEventListener("message", (event: MessageEvent) => { arrayStore, caseArgs, ); - const { code: instrumentedCode, ok: instrumentOk } = - instrumentUserJsForLineTracking(code); - const codeToRun = instrumentOk ? instrumentedCode : code; + const { code: codeToRun } = instrumentUserJsForLineTracking(code); const prefixedCode = `${globalDefinitionsPrefix}\n${codeToRun}`; const startTimestamp = performance.now(); try { diff --git a/src/features/treeViewer/hooks/useArgumentsParsing.ts b/src/features/treeViewer/hooks/useArgumentsParsing.ts index 834176c6..3d8eb592 100644 --- a/src/features/treeViewer/hooks/useArgumentsParsing.ts +++ b/src/features/treeViewer/hooks/useArgumentsParsing.ts @@ -5,6 +5,7 @@ import { generate } from "short-uuid"; import { buildCaseArgsContentSignature, + getArgumentDisplayLabel, isArgumentArrayType, isArgumentTreeType, } from "#/entities/argument/lib"; @@ -441,6 +442,7 @@ const parseArrayArgument = ( childNames: childArgs?.map((arg) => arg.name), order: arg.order, argType: arg.type, + displayLabel: getArgumentDisplayLabel(arg), }), ); if (newItems) { diff --git a/src/i18n/en/index.ts b/src/i18n/en/index.ts index a127b7b0..27614905 100644 --- a/src/i18n/en/index.ts +++ b/src/i18n/en/index.ts @@ -5,6 +5,11 @@ const en: BaseTranslation = { ADD_ARGUMENT: "Add argument", ADD_NEW_SOLUTION: "Add new solution", ARGUMENTS: "Arguments", + ARGUMENT_DISPLAY_NAME: "Display name", + ARGUMENT_DISPLAY_NAME_HELPER: + "Shown on argument inputs. Duplicates are allowed; empty uses arg-1, arg-2, … by order.", + ARGUMENT_DISPLAY_NAME_PLACEHOLDER: "e.g. nums", + ARGUMENT_RENAME: "Rename display name", BROWSE: "Browse", BROWSE_PROJECTS: "Browse Projects", BACK: "Back", diff --git a/src/i18n/i18n-types.ts b/src/i18n/i18n-types.ts index 194cc2ac..2c6ee9f0 100644 --- a/src/i18n/i18n-types.ts +++ b/src/i18n/i18n-types.ts @@ -48,6 +48,22 @@ type RootTranslation = { * A​r​g​u​m​e​n​t​s */ ARGUMENTS: string + /** + * D​i​s​p​l​a​y​ ​n​a​m​e + */ + ARGUMENT_DISPLAY_NAME: string + /** + * S​h​o​w​n​ ​o​n​ ​a​r​g​u​m​e​n​t​ ​i​n​p​u​t​s​.​ ​D​u​p​l​i​c​a​t​e​s​ ​a​r​e​ ​a​l​l​o​w​e​d​;​ ​e​m​p​t​y​ ​u​s​e​s​ ​a​r​g​-​1​,​ ​a​r​g​-​2​,​ ​…​ ​b​y​ ​o​r​d​e​r​. + */ + ARGUMENT_DISPLAY_NAME_HELPER: string + /** + * e​.​g​.​ ​n​u​m​s + */ + ARGUMENT_DISPLAY_NAME_PLACEHOLDER: string + /** + * R​e​n​a​m​e​ ​d​i​s​p​l​a​y​ ​n​a​m​e + */ + ARGUMENT_RENAME: string /** * B​r​o​w​s​e */ @@ -890,6 +906,22 @@ export type TranslationFunctions = { * Arguments */ ARGUMENTS: () => LocalizedString + /** + * Display name + */ + ARGUMENT_DISPLAY_NAME: () => LocalizedString + /** + * Shown on argument inputs. Duplicates are allowed; empty uses arg-1, arg-2, … by order. + */ + ARGUMENT_DISPLAY_NAME_HELPER: () => LocalizedString + /** + * e.g. nums + */ + ARGUMENT_DISPLAY_NAME_PLACEHOLDER: () => LocalizedString + /** + * Rename display name + */ + ARGUMENT_RENAME: () => LocalizedString /** * Browse */ diff --git a/src/packages/dstruct-runner/python/array_tracker.py b/src/packages/dstruct-runner/python/array_tracker.py index 18e741f5..0bfe414d 100644 --- a/src/packages/dstruct-runner/python/array_tracker.py +++ b/src/packages/dstruct-runner/python/array_tracker.py @@ -16,8 +16,8 @@ class ListData(TypedDict): ids: List[str] entities: Dict[str, ListItemData] -class ListOptions(TypedDict): - name: str +class ListOptions(TypedDict, total=False): + displayLabel: str class TrackedList(list, Generic[T]): """A proxy-like list class that tracks all operations performed on it.""" @@ -33,7 +33,7 @@ def __init__(self, items: List[T], name: str, callstack: List[CallFrame]) -> Non "addArray", args={ "arrayData": self._generate_list_data(items), - "options": {"name": name} + "options": {"displayLabel": name}, } ) diff --git a/src/packages/dstruct-runner/python/array_tracker_transformer.py b/src/packages/dstruct-runner/python/array_tracker_transformer.py index 87547179..339edf3b 100644 --- a/src/packages/dstruct-runner/python/array_tracker_transformer.py +++ b/src/packages/dstruct-runner/python/array_tracker_transformer.py @@ -1,6 +1,5 @@ import ast -from typing import List, Dict, Any, Union, TypedDict, TypeVar -from shared_types import CallFrame +from typing import List, Dict, Any, Union, TypedDict, TypeVar, Optional T = TypeVar('T') @@ -12,8 +11,40 @@ class ListData(TypedDict): ids: List[str] entities: Dict[str, ListItemData] -class ListOptions(TypedDict): - name: str +class ListOptions(TypedDict, total=False): + displayLabel: str + +def attach_ast_parents(root: ast.AST) -> None: + """Set ``node.parent`` for every node so transforms can infer binding names.""" + + def visit(node: ast.AST, parent: Optional[ast.AST]) -> None: + setattr(node, "parent", parent) + for child in ast.iter_child_nodes(node): + visit(child, node) + + visit(root, None) + + +def _infer_list_literal_display_name(list_node: ast.List) -> Optional[str]: + parent = getattr(list_node, "parent", None) + if parent is None: + return None + + if isinstance(parent, ast.Assign) and parent.value is list_node: + targets = parent.targets + if targets and all(isinstance(target, ast.Name) for target in targets): + return targets[0].id + + if isinstance(parent, ast.AnnAssign) and parent.value is list_node: + if isinstance(parent.target, ast.Name): + return parent.target.id + + if isinstance(parent, ast.AugAssign) and parent.value is list_node: + if isinstance(parent.target, ast.Name): + return parent.target.id + + return None + class ListTrackingTransformer(ast.NodeTransformer): """AST transformer that replaces list operations with tracked list operations.""" @@ -22,11 +53,14 @@ def __init__(self) -> None: super().__init__() self.counter = 0 - def visit_List(self, node): - # Replace [1, 2, 3] with TrackedList([1, 2, 3], "auto_list_N", __callstack__) + def visit_List(self, node: ast.List) -> ast.Call: self.generic_visit(node) - list_name = f"auto_list_{self.counter}" - self.counter += 1 + inferred = _infer_list_literal_display_name(node) + if inferred is not None: + list_name: str = inferred + else: + list_name = f"auto_list_{self.counter}" + self.counter += 1 return ast.Call( func=ast.Name(id="TrackedList", ctx=ast.Load()), args=[ diff --git a/src/packages/dstruct-runner/python/exec.py b/src/packages/dstruct-runner/python/exec.py index d96fe458..814e64e6 100644 --- a/src/packages/dstruct-runner/python/exec.py +++ b/src/packages/dstruct-runner/python/exec.py @@ -4,7 +4,7 @@ import sys import json from array_tracker import TrackedList -from array_tracker_transformer import ListTrackingTransformer +from array_tracker_transformer import ListTrackingTransformer, attach_ast_parents from collection_tracker import TrackedDict, TrackedFrozenSet, TrackedSet from execution_location import clear_execution_source, set_execution_source from line_tracking_transformer import LineTrackingTransformer @@ -182,6 +182,7 @@ def safe_exec(code: str, args: list | None = None) -> ExecutionResult: ) # Transform the AST to track list ops, then statement-level line probes + attach_ast_parents(tree) list_transformer = ListTrackingTransformer() new_tree = list_transformer.visit(tree) line_transformer = LineTrackingTransformer() diff --git a/src/packages/dstruct-runner/python/test_exec.py b/src/packages/dstruct-runner/python/test_exec.py index 2dfed4f8..c67f9f9c 100644 --- a/src/packages/dstruct-runner/python/test_exec.py +++ b/src/packages/dstruct-runner/python/test_exec.py @@ -80,6 +80,23 @@ def run(): ] self.assertGreaterEqual(len(read_frames), 3) + def test_inline_list_literal_infers_display_label(self) -> None: + code = """ +def solve(): + values = [1, 2] + return len(values) +""" + result = safe_exec(code, None) + self.assertIsNone(result["error"]) + labeled = [ + frame + for frame in result["callstack"] + if frame.get("name") == "addArray" + and frame.get("args", {}).get("options", {}).get("displayLabel") + == "values" + ] + self.assertEqual(len(labeled), 1) + def test_inline_dict_literal_emits_map_frames(self) -> None: code = """ def solve(): diff --git a/src/server/api/routers/project.ts b/src/server/api/routers/project.ts index f27a8c6d..6eeaba83 100644 --- a/src/server/api/routers/project.ts +++ b/src/server/api/routers/project.ts @@ -39,36 +39,42 @@ const getDefaultArguments = (category: ProjectCategory): ArgumentObjectMap => { case ProjectCategory.SLIDING_WINDOW: case ProjectCategory.BACKTRACKING: case ProjectCategory.DYNAMIC_PROGRAMMING: - case ProjectCategory.BIT_MANIPULATION: + case ProjectCategory.BIT_MANIPULATION: { + const argumentId = generateShortUuid(); return { - array: { - name: "array", + [argumentId]: { + name: argumentId, order: 0, type: ArgumentType.ARRAY, input: "[1,2,3]", }, }; + } case ProjectCategory.BINARY_TREE: - case ProjectCategory.BST: + case ProjectCategory.BST: { + const argumentId = generateShortUuid(); return { - head: { - name: "head", + [argumentId]: { + name: argumentId, order: 0, type: ArgumentType.BINARY_TREE, input: "[1,2,3]", }, }; + } - case ProjectCategory.LINKED_LIST: + case ProjectCategory.LINKED_LIST: { + const argumentId = generateShortUuid(); return { - head: { - name: "head", + [argumentId]: { + name: argumentId, order: 0, type: ArgumentType.LINKED_LIST, input: "[1,2,3]", }, }; + } case ProjectCategory.GRAPH: return { @@ -660,8 +666,9 @@ export const projectRouter = createTRPCRouter({ }); if (project?.category === ProjectCategory.BINARY_TREE) { - args["head"] = { - name: "head", + const argumentId = generateShortUuid(); + args[argumentId] = { + name: argumentId, order: 0, type: ArgumentType.BINARY_TREE, input: "[]", diff --git a/src/shared/ui/molecules/JsonInput.tsx b/src/shared/ui/molecules/JsonInput.tsx index a72eec20..a1711bf8 100644 --- a/src/shared/ui/molecules/JsonInput.tsx +++ b/src/shared/ui/molecules/JsonInput.tsx @@ -11,11 +11,18 @@ type JsonInputProps = Omit & { value: string; onChange: (value: string) => void; validationSchema: Joi.Schema; + /** Debounce for persisting JSON text (passed to `DebouncedInput`). */ + timeout?: number; + /** Rendered in `slotProps.input.endAdornment` before any inherited adornment (e.g. rename control). */ + suffixSlot?: React.ReactNode; }; export const JsonInput: React.FC = ({ onChange, validationSchema, + timeout, + suffixSlot, + slotProps, ...restProps }) => { const { LL } = useI18nContext(); @@ -42,6 +49,14 @@ export const JsonInput: React.FC = ({ } }; + const inputSlotProps = slotProps?.input; + const inheritedEndAdornment = + typeof inputSlotProps === "object" && + inputSlotProps !== null && + "endAdornment" in inputSlotProps + ? inputSlotProps.endAdornment + : null; + return ( = ({ error={!!inputError} helperText={inputError} fullWidth + timeout={timeout} + slotProps={{ + ...slotProps, + input: { + ...(typeof inputSlotProps === "object" && inputSlotProps !== null + ? inputSlotProps + : {}), + endAdornment: ( + <> + {suffixSlot} + {inheritedEndAdornment} + + ), + }, + }} {...restProps} /> );