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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
} from '../../../../../services/graphical-scope'
import { useOpenPLCStore } from '../../../../../store'
import { cn } from '../../../../../utils/cn'
import { getLiteralType } from '../../../../../utils/keywords'
import { getLiteralType, isLegalIdentifier } from '../../../../../utils/keywords'
import { toast } from '../../../../_features/[app]/toast/use-toast'
import { useBoundPou } from '../../../../_features/[workspace]/editor/graphical/active-context'
import { buildGenericNode } from '../../../../_molecules/graphical-editor/fbd/fbd-utils/nodes'
Expand Down Expand Up @@ -130,6 +130,16 @@ const FBDBlockAutoComplete = forwardRef<HTMLDivElement, FBDBlockAutoCompleteProp
return
}

// If the entry can't be a new variable NAME — a member/array reference
// (`some_struct.field`, `arr[3]`), a typed literal (`T#500ms`), a reserved
// word, etc. — don't try to create a variable. Bind it to the block
// verbatim as a constant/reference; strucpp validates the expression. New
// local-variable creation is only for plain, legal identifiers.
if (!isLegalIdentifier(variableName)[0]) {
submitVariableToBlock({ name: variableName } as PLCVariable)
return
Comment on lines +138 to +140

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Use an explicit unresolved-expression binding type in both autocomplete paths.

Both paths persist only a name where the shared PLCVariable contract requires additional fields.

  • src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx#L138-L140: replace the as PLCVariable cast with a supported expression-binding representation.
  • src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx#L195-L202: use the same representation and ensure connected-variable consumers handle it.
📍 Affects 2 files
  • src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx#L138-L140 (this comment)
  • src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx#L195-L202
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx`
around lines 138 - 140, Replace the partial PLCVariable cast in the FBD
autocomplete path at
src/frontend/components/_atoms/graphical-editor/fbd/autocomplete/index.tsx:138-140
with the supported unresolved-expression binding representation. Apply the same
representation in the ladder autocomplete path at
src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx:195-202,
and update connected-variable consumers to handle that representation
consistently.

}

const { rung: freshRung, node } = getFBDPouVariablesRungNodeAndEdges(pouName, pous, fbdFlows, {
nodeId: block.id,
})
Expand Down
8 changes: 8 additions & 0 deletions src/frontend/components/_atoms/graphical-editor/fbd/block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RefreshIcon } from '../../../../assets/icons/interface/Refresh'
import { useOpenPLCStore } from '../../../../store'
import { checkVariableName } from '../../../../store/slices/project/validation/variables'
import { cn } from '../../../../utils/cn'
import { isLegalIdentifier } from '../../../../utils/keywords'
import { toast } from '../../../_features/[app]/toast/use-toast'
import { useBoundEditorModel, useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context'
import { HighlightedTextArea } from '../../highlighted-textarea'
Expand Down Expand Up @@ -504,6 +505,13 @@ const Block = <T extends object>(block: BlockProps<T>) => {
if (matchingVariable) {
variableToLink = matchingVariable
} else if (createIfNotFound) {
// An entry that can't be a new variable NAME — a member/array reference,
// a typed literal (`T#500ms`), a reserved word — is bound to the block
// verbatim as a constant/reference instead of erroring.
if (!isLegalIdentifier(variableNameToSubmit)[0]) {
updateNodeVariable({ name: variableNameToSubmit })
return
}
Comment on lines +508 to +514

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Apply non-identifier handling to every block-commit path.

The new branch is unreachable for the ordinary textarea commit because both handlers pass createIfNotFound = false.

  • src/frontend/components/_atoms/graphical-editor/fbd/block.tsx#L508-L514: handle non-identifiers before the createIfNotFound condition.
  • src/frontend/components/_atoms/graphical-editor/ladder/block.tsx#L606-L612: apply the same restructuring.
📍 Affects 2 files
  • src/frontend/components/_atoms/graphical-editor/fbd/block.tsx#L508-L514 (this comment)
  • src/frontend/components/_atoms/graphical-editor/ladder/block.tsx#L606-L612
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/frontend/components/_atoms/graphical-editor/fbd/block.tsx` around lines
508 - 514, The non-identifier branch is currently unreachable on ordinary
textarea commits because it is gated by createIfNotFound. In
src/frontend/components/_atoms/graphical-editor/fbd/block.tsx#L508-L514 and
src/frontend/components/_atoms/graphical-editor/ladder/block.tsx#L606-L612,
restructure each block-commit handler so isLegalIdentifier(variableNameToSubmit)
is checked before the createIfNotFound condition, updating the node variable and
returning for non-identifiers while preserving existing creation behavior for
legal identifiers.

const pouData = pous.find((p) => p.name === pouName)
pushToHistory(pouName, {
variables: pouData?.interface?.variables ?? [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
} from '../../../../../services/graphical-scope'
import { useOpenPLCStore } from '../../../../../store'
import { cn } from '../../../../../utils/cn'
import { getLiteralType } from '../../../../../utils/keywords'
import { getLiteralType, isLegalIdentifier } from '../../../../../utils/keywords'
import { toast } from '../../../../_features/[app]/toast/use-toast'
import { useBoundPou } from '../../../../_features/[workspace]/editor/graphical/active-context'
import { GraphicalEditorAutocomplete } from '../../autocomplete'
Expand Down Expand Up @@ -187,6 +187,21 @@ const VariablesBlockAutoComplete = forwardRef<HTMLDivElement, VariablesBlockAuto
})
if (!rung || !node) return

// If the entry can't be a new variable NAME — a member/array reference
// (`some_struct.field`, `arr[3]`), a typed literal (`T#500ms`), a reserved
// word, etc. — don't try to create a variable. Bind it to the node
// verbatim as a constant/reference; strucpp validates the expression. New
// local-variable creation is only for plain, legal identifiers.
if (!isLegalIdentifier(variableName)[0]) {
updateNode({
editorName: pouName,
rungId: rung.id,
nodeId: node.id,
node: { ...node, data: { ...node.data, variable: { name: variableName } } },
})
return
Comment on lines +190 to +202

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Use submitVariableToBlock for expression bindings.

This branch updates only the variable node, while submitVariableToBlock also refreshes the connected block’s connectedVariables. If the node is connected, entering my_struct.field leaves the block holding the previous binding. Route this path through the shared helper after introducing a supported unresolved-expression binding type.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@src/frontend/components/_atoms/graphical-editor/ladder/autocomplete/index.tsx`
around lines 190 - 202, Update the invalid-identifier branch to use
submitVariableToBlock instead of updating only the node, so connected blocks
refresh their connectedVariables. Add or reuse a supported unresolved-expression
binding type for values such as member and array references, then pass that
binding through submitVariableToBlock while preserving the existing verbatim
expression name.

}

const variableType = newVariableTypeForExpected(expectedType)

const res = createVariable({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { useOpenPLCStore } from '../../../../store'
import { LibraryState } from '../../../../store/slices/library'
import { checkVariableName } from '../../../../store/slices/project/validation/variables'
import { cn } from '../../../../utils/cn'
import { isLegalIdentifier } from '../../../../utils/keywords'
import { toast } from '../../../_features/[app]/toast/use-toast'
import { useBoundEditorModel, useBoundPou } from '../../../_features/[workspace]/editor/graphical/active-context'
import { updateDiagramElementsPosition } from '../../../_molecules/graphical-editor/ladder/rung/ladder-utils/elements/diagram'
Expand Down Expand Up @@ -602,6 +603,13 @@ const Block = <T extends object>(block: BlockProps<T>) => {
if (matchingVariable) {
variableToLink = matchingVariable
} else if (createIfNotFound) {
// An entry that can't be a new variable NAME — a member/array reference,
// a typed literal (`T#500ms`), a reserved word — is bound to the block
// verbatim as a constant/reference instead of erroring.
if (!isLegalIdentifier(variableNameToSubmit)[0]) {
updateNodeVariable({ name: variableNameToSubmit })
return
}
const project = useOpenPLCStore.getState().project
const currentPou = project.data.pous.find((p) => p.name === pouName)
pushToHistory(pouName, {
Expand Down
19 changes: 15 additions & 4 deletions src/frontend/services/graphical-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,17 @@ import type { PLCVariable } from '../../middleware/shared/ports/types'
import { getVariableRestrictionType, validateVariableType } from '../utils/PLC/validate-variable-type'
import { getScopedQueryApi } from './st-lsp'

/** LSP `CompletionItemKind.Variable` — strucpp's kind for in-scope variables and instance/struct members. */
/**
* LSP `CompletionItemKind`s that denote a value symbol bindable to a box.
* strucpp emits `Variable` (6) for in-scope variables and FUNCTION_BLOCK
* instance members (`TON0.Q`), but `Field` (5) for STRUCT members
* (`my_struct.field`). Both must be accepted, or struct-member access never
* autocompletes or validates.
*/
const LSP_KIND_VARIABLE = 6
const LSP_KIND_FIELD = 5
const isValueCompletionKind = (kind: number | undefined): boolean =>
kind === LSP_KIND_VARIABLE || kind === LSP_KIND_FIELD

/** Max instance/struct variables to drill into when a type-filtered search has no direct hits. */
const SCOPE_EXPAND_LIMIT = 8
Expand Down Expand Up @@ -92,7 +101,7 @@ export async function getScopeCompletions(
const { anchor, segment } = splitExpression(value)
const items = await api.completeInScope(pouName, anchor)
const needle = segment.toLowerCase()
const matching = items.filter((item) => item.kind === LSP_KIND_VARIABLE && item.label.toLowerCase().includes(needle))
const matching = items.filter((item) => isValueCompletionKind(item.kind) && item.label.toLowerCase().includes(needle))

const direct = matching
.filter((item) => {
Expand Down Expand Up @@ -120,7 +129,7 @@ export async function getScopeCompletions(
const memberAnchor = `${anchor}${instance.label}.`
const members = await api.completeInScope(pouName, memberAnchor)
return members
.filter((m) => m.kind === LSP_KIND_VARIABLE && m.type && validateVariableType(m.type, expectedType).isValid)
.filter((m) => isValueCompletionKind(m.kind) && m.type && validateVariableType(m.type, expectedType).isValid)
.map((m) => ({ label: `${instance.label}.${m.label}`, insertText: memberAnchor + m.label, type: m.type }))
}),
)
Expand All @@ -146,7 +155,9 @@ export async function resolveScopeExpressionType(pouName: string, expression: st
if (items.length === 0) return { status: 'unavailable' }

const { name, indexed } = stripSubscript(segment)
const match = items.find((item) => item.kind === LSP_KIND_VARIABLE && item.label.toLowerCase() === name.toLowerCase())
const match = items.find(
(item) => isValueCompletionKind(item.kind) && item.label.toLowerCase() === name.toLowerCase(),
)
if (!match || !match.type) return { status: 'unknown' }

if (indexed) {
Expand Down
28 changes: 28 additions & 0 deletions src/frontend/utils/PLC/__tests__/pou-signature-serializer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,34 @@ describe('serializePouSignatureToST', () => {
expect(text.split('\n')[position.line]).toBe('in1.')
})

it('re-emits external variables as plain VAR so globals resolve in the throwaway doc', () => {
const pou = makePou({
name: 'Main',
pouType: 'program',
interface: {
variables: [
{
id: 'g1',
name: 'some_global_complex',
class: 'external',
type: { definition: 'derived', value: 'testing_arr' },
documentation: '',
debug: false,
location: '',
},
],
},
body: { language: 'fbd', value: {} as never },
})
const { text } = serializePouScopeForQuery(pou, 'some_global_complex.')
// The external is rewritten to a self-contained local VAR (type inline)
// so strucpp resolves the global + its struct/array members without a
// matching VAR_GLOBAL in this throwaway document.
expect(text).toContain('some_global_complex : testing_arr;')
expect(text).not.toContain('VAR_EXTERNAL')
expect(text).toContain('some_global_complex.')
})

it('keeps the function return type while swapping only the name', () => {
const pou = makePou({
name: 'AbsInt',
Expand Down
12 changes: 11 additions & 1 deletion src/frontend/utils/PLC/pou-signature-serializer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,17 @@ export function serializePouScopeForQuery(
pou.pouType === 'function' && pou.interface?.returnType
? `${startKeyword} ${name} : ${pou.interface.returnType}`
: `${startKeyword} ${name}`
const variables = generateIecVariablesToString(pou.interface?.variables ?? [])
// External variables (`VAR_EXTERNAL`) reference resource globals declared in a
// separate CONFIGURATION document. This throwaway query doc isn't part of that
// configuration, so strucpp can't resolve a bare `VAR_EXTERNAL` here — the
// global and its struct/array members would come back unknown (the box shows
// yellow, no autocomplete). Re-emit externals as plain `VAR`: they carry their
// real type inline, so the symbol and its members resolve self-containedly.
// Scope-query-only — the POU's real stub keeps `VAR_EXTERNAL`.
const scopeVariables = (pou.interface?.variables ?? []).map((variable) =>
variable.class === 'external' ? { ...variable, class: 'local' as const } : variable,
)
const variables = generateIecVariablesToString(scopeVariables)
const prefix = `${declaration}\n${variables}\n`
// `prefix` ends with '\n', so split length - 1 is the 0-indexed line
// the body expression sits on.
Expand Down
Loading