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
2 changes: 1 addition & 1 deletion binary-versions.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"strucpp": {
"version": "v0.6.0",
"version": "v0.6.1",
"repository": "Autonomy-Logic/STruCpp"
}
}
30 changes: 9 additions & 21 deletions src/frontend/services/graphical-scope.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 Autonomy / OpenPLC Project
/**
Expand Down Expand Up @@ -71,19 +71,6 @@
return { anchor: value.slice(0, lastDot + 1), segment: value.slice(lastDot + 1) }
}

/** Strip a trailing array subscript (`foo[3]` → `foo`). Returns the name and whether a subscript was present. */
function stripSubscript(segment: string): { name: string; indexed: boolean } {
const bracket = segment.indexOf('[')
if (bracket < 0) return { name: segment, indexed: false }
return { name: segment.slice(0, bracket), indexed: true }
}

/** Pull the element type out of an `ARRAY [..] OF <type>` detail string. */
function arrayElementType(type: string): string | undefined {
const match = type.match(/\bOF\s+([A-Za-z_][A-Za-z0-9_]*)/i)
return match ? match[1] : undefined
}

/**
* Autocomplete candidates for `value` typed into a box in `pouName`'s
* scope. `value` is the full current box text (e.g. `TON0.Q`, `mo`).
Expand Down Expand Up @@ -138,8 +125,14 @@

/**
* Resolve the IEC type of `expression` in `pouName`'s scope. Handles bare
* identifiers, member chains (`TON0.Q`, `s.a.b`) and 1-D array element
* access (`arr[3]`). See {@link ScopeTypeResult} for the tri-state result.
* identifiers, member chains (`TON0.Q`, `s.a.b`) and array element access
* (`arr[3]`, `grid[1,2]`). See {@link ScopeTypeResult} for the tri-state result.
*
* Array elements need no special casing: strucpp lists each in-bounds element
* as its own symbol typed as the element type, so `arr[3]` matches by label
* like any other. That also makes the bounds authoritative — `arr[99]` simply
* isn't a symbol, so it resolves `unknown` and the box is flagged, which a
* client-side subscript-stripping heuristic could never detect.
*/
export async function resolveScopeExpressionType(pouName: string, expression: string): Promise<ScopeTypeResult> {
const api = getScopedQueryApi()
Expand All @@ -154,16 +147,11 @@
// context yet — treat as unavailable rather than flag a false invalid.
if (items.length === 0) return { status: 'unavailable' }

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

if (indexed) {
const element = arrayElementType(match.type)
return element ? { status: 'resolved', type: element } : { status: 'unknown' }
}
return { status: 'resolved', type: match.type }
}

Expand Down
138 changes: 138 additions & 0 deletions src/frontend/services/st-lsp/__tests__/project-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,93 @@ function makeStubService() {
}
}

/** A POU whose only variable is bound to a producer alias by NAME — the
* single-field location model the LSP projection has to resolve. */
function makeAliasPou(name: string): PLCPou {
return {
name,
pouType: 'program',
interface: {
variables: [
{
id: '1',
name: 'label2',
class: 'local',
type: { definition: 'base-type', value: 'INT' },
documentation: '',
debug: false,
location: 'label2',
},
],
},
body: { language: 'st', value: 'x := 1;' },
documentation: '',
} as PLCPou
}

/** Seed a Runtime v4 target plus one Modbus holding-register point carrying
* `alias`, so the store's alias index resolves it to `%IW0`. */
function seedAliasProducer(alias: string) {
const { deviceActions, projectActions } = openPLCStoreBase.getState()
deviceActions.setAvailableOptions({
availableBoards: new Map([
[
'OpenPLC Runtime v4',
{
compiler: 'openplc-compiler',
core: 'rt-v4',
preview: '',
specs: {},
capabilities: {
pinMapping: false,
vppIo: false,
modbusTcpRemote: true,
ethercat: true,
modbusTcpServer: true,
opcuaServer: true,
s7Server: true,
debuggerTransports: ['websocket'],
pythonFunctionBlocks: true,
arduinoApiCompletions: false,
hasRuntimeStats: true,
isInProcessSimulator: false,
directUsbUpload: false,
},
},
],
]) as never,
})
deviceActions.setDeviceBoard('OpenPLC Runtime v4')

const current = openPLCStoreBase.getState().project
projectActions.setProject({
...current,
data: {
...current.data,
remoteDevices: [
{
name: 'Dev1',
protocol: 'modbus-tcp',
modbusTcpConfig: { host: '127.0.0.1', port: 502, slaveId: 1, timeout: 1000, ioGroups: [] },
},
],
},
})
projectActions.addIOGroup('Dev1', {
id: 'g1',
name: 'group-g1',
functionCode: '3',
cycleTime: 100,
offset: '0',
length: 1,
errorHandling: 'keep-last-value',
ioPoints: [],
})
const pointId =
openPLCStoreBase.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints![0].id
projectActions.updateIOPointAlias('Dev1', 'g1', pointId, alias)
}

function setProjectPous(pous: PLCPou[]) {
openPLCStoreBase.setState((s) => ({
...s,
Expand Down Expand Up @@ -218,6 +305,57 @@ describe('attachProjectSync', () => {
expect(new Set(versions).size).toBe(versions.length)
handle.dispose()
})

// `AT <alias>` is not valid IEC ST — strucpp abandons the whole VAR block on
// it, so every symbol after the first alias-bound variable falls out of the
// POU's scope (no autocomplete, red boxes in the LD/FBD editors).
it('never publishes a bare alias name as a location', () => {
setProjectPous([makeAliasPou('Main')])
const service = makeStubService()
const handle = attachProjectSync(service)

const text = service.openDocument.mock.calls.find((c) => c[0] === 'inmemory://pou/Main.st')?.[1] as string
expect(text).not.toContain('AT label2')
expect(text).toContain('label2 : INT;')
// The store still holds the alias-name form — only the LSP projection is
// resolved.
expect(openPLCStoreBase.getState().project.data.pous[0].interface?.variables?.[0].location).toBe('label2')
handle.dispose()
})

it('publishes the address an alias currently resolves to', () => {
seedAliasProducer('label2')
setProjectPous([makeAliasPou('Main')])
const service = makeStubService()
const handle = attachProjectSync(service)

const text = service.openDocument.mock.calls.find((c) => c[0] === 'inmemory://pou/Main.st')?.[1] as string
expect(text).toContain('label2 : INT AT %IW0;')
handle.dispose()
})

it('re-publishes when producer state changes without a POU edit', () => {
seedAliasProducer('label2')
setProjectPous([makeAliasPou('Main')])
const service = makeStubService()
const handle = attachProjectSync(service)
service.changeDocument.mockClear()

// Dropping the producer touches only `remoteDevices` — the POU array is
// untouched, so only the alias-index subscription can catch it. Without
// it the stub would keep advertising the now-dead `%IW0`.
openPLCStoreBase.setState((s) => ({
...s,
project: { ...s.project, data: { ...s.project.data, remoteDevices: [] } },
}))

expect(service.changeDocument).toHaveBeenCalledTimes(1)
const [uri, text] = service.changeDocument.mock.calls[0] as [string, string]
expect(uri).toBe('inmemory://pou/Main.st')
expect(text).toContain('label2 : INT;')
expect(text).not.toContain('%IW0')
handle.dispose()
})
})

function makeSystemLibrary(name: string, version: string = '1.0.0'): SystemLibrary {
Expand Down
14 changes: 10 additions & 4 deletions src/frontend/services/st-lsp/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 Autonomy / OpenPLC Project
/**
Expand Down Expand Up @@ -297,15 +297,20 @@
await scopeWarmReady
const connection = serviceConnection
if (!connection) return []
const pou = openPLCStoreBase.getState().project.data.pous.find((p) => p.name === pouName)
const { project, projectActions } = openPLCStoreBase.getState()
const pou = project.data.pous.find((p) => p.name === pouName)
if (!pou) return []
// Alias-bound locations must be resolved to literal `%…` addresses or the
// query doc's VAR block fails to parse and strucpp returns no candidates
// for the whole POU. See `serializePouScopeForQuery`.
const aliasIndex = projectActions.getAliasIndex()

// Once warm, fresh per-query docs resolve instantly; a single short retry
// covers a rare transient miss. Each attempt uses a unique URI + unique
// synthetic POU name so docs never collide.
for (let attempt = 0; attempt < SCOPE_QUERY_MAX_ATTEMPTS; attempt += 1) {
const id = (scopeQuerySeq += 1)
const { text, position } = serializePouScopeForQuery(pou, prefix, id)
const { text, position } = serializePouScopeForQuery(pou, prefix, id, aliasIndex)
const uri = `inmemory://scopequery/${id}.st`
const items = await requestOnce(connection, uri, text, position)
if (items.length > 0) return items
Expand Down Expand Up @@ -345,10 +350,11 @@
}
await new Promise((r) => setTimeout(r, SCOPE_WARMUP_INITIAL_DELAY_MS))
for (let poll = 0; poll < SCOPE_WARMUP_MAX_POLLS; poll += 1) {
const pou = openPLCStoreBase.getState().project.data.pous[0]
const { project, projectActions } = openPLCStoreBase.getState()
const pou = project.data.pous[0]
if (pou) {
const id = (scopeQuerySeq += 1)
const { text, position } = serializePouScopeForQuery(pou, '', id)
const { text, position } = serializePouScopeForQuery(pou, '', id, projectActions.getAliasIndex())
const uri = `inmemory://scopequery/warmup-${id}.st`
const items = await requestOnce(connection, uri, text, position)
if (items.length > 0) {
Expand Down
33 changes: 31 additions & 2 deletions src/frontend/services/st-lsp/project-sync.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2025 Autonomy / OpenPLC Project
/**
Expand Down Expand Up @@ -87,6 +87,15 @@
const snapshot = emptySnapshot()
let disposed = false

// A variable's `location` holds EITHER a producer alias name OR a literal
// `%addr`. `AT <alias>` is not valid IEC ST — strucpp abandons the whole VAR
// block on it, so every symbol after the first alias-bound variable vanishes
// from the POU's scope. The LSP therefore sees the same resolved addresses
// the compiler does (`getCompileReadyProjectData`); only the projection is
// resolved, the store keeps the alias names for display. The store memoizes
// this on producer-state identity, so calling it per reconcile is cheap.
const aliasIndex = (): ReadonlyMap<string, string> => openPLCStoreBase.getState().projectActions.getAliasIndex()

// Reconcile a single fixed-URI synthesized document (data types, resource
// globals, softmotion axes …) against the worker: open on first non-empty
// text, didChange on a text change, didClose when it becomes empty. Centralised
Expand Down Expand Up @@ -119,7 +128,7 @@
// The project's configuration-level `VAR_GLOBAL`s wrapped in a CONFIGURATION,
// so a POU's `VAR_EXTERNAL` resolves against a matching global.
function reconcileResourceGlobals(globals: PLCVariable[]): void {
reconcileSyntheticDoc(RESOURCE_GLOBALS_URI, serializeResourceGlobalsToST(globals))
reconcileSyntheticDoc(RESOURCE_GLOBALS_URI, serializeResourceGlobalsToST(globals, aliasIndex()))
}

// A `VAR_GLOBAL <axis> : AXIS_REF_SM3` per recognized CiA 402 drive, so editor
Expand All @@ -132,6 +141,8 @@
function reconcile(pous: PLCPou[]): void {
if (disposed) return

// Read once per reconcile — every POU stub resolves against the same index.
const resolvedAliases = aliasIndex()
const seenNames = new Set<string>()
const seenUris = new Set<string>()
// The synthesized documents (data types, resource globals, SoftMotion axes)
Expand All @@ -149,7 +160,7 @@
// Axes are surfaced as ambient globals (SOFTMOTION_GLOBALS_URI), so the
// editor documents keep the exact line layout the user wrote and
// go-to-definition line mapping stays correct.
const { text: nextText, bodyLineOffset } = serializePouSignatureToSTWithBodyOffset(pou)
const { text: nextText, bodyLineOffset } = serializePouSignatureToSTWithBodyOffset(pou, resolvedAliases)

// POU name unchanged but URI switched (body language change).
// Send didClose for the previous URI before didOpen on the new.
Expand Down Expand Up @@ -222,6 +233,23 @@
(state) => state.project.data.remoteDevices,
(remoteDevices) => reconcileSoftMotionGlobals(remoteDevices),
)
// Every document that declares variables is serialized against the alias →
// address index, so a producer-only change must re-emit them. Renaming an
// alias already cascades into `project.data.pous` (via `renameAlias`) and
// reconciles through the subscription above, but a pure *re-address*
// (`recalculateIecAddresses` compacting after an IO point is removed)
// touches only the producers — the stubs would otherwise keep the old
// `%addr`. Selecting the index itself is the exact trigger: the store
// memoizes it on producer-state identity, so this selector is a handful of
// `===` checks and the listener fires only when the index really moved.
const unsubscribeAliasIndex = openPLCStoreBase.subscribe(
(state) => state.projectActions.getAliasIndex(),
() => {
const live = openPLCStoreBase.getState()
reconcileResourceGlobals(live.project.data.configurations.resource.globalVariables)
reconcile(live.project.data.pous)
},
)

// Initial reconcile against whatever is already in the store. The
// synthesized globals/types load first so any POU that references
Expand Down Expand Up @@ -257,6 +285,7 @@
unsubscribeDataTypes()
unsubscribeResourceGlobals()
unsubscribeRemoteDevices()
unsubscribeAliasIndex()
// Close every doc we'd opened so the worker stays consistent
// if the service is restarted in the same session.
for (const uri of snapshot.contentByUri.keys()) {
Expand Down
32 changes: 32 additions & 0 deletions src/frontend/store/__tests__/project-slice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2900,6 +2900,38 @@ describe('createProjectSlice', () => {
})
})

describe('getAliasIndex', () => {
beforeEach(() => {
seedRuntimeV4Board(store)
})

it('exposes the live alias → address map', () => {
seedRemoteDevice(store, makeRemoteDevice('Dev1'))
store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2)) // %IW0, %IW1
const pointId = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints![0].id
store.getState().projectActions.updateIOPointAlias('Dev1', 'g1', pointId, 'flow')

expect(store.getState().projectActions.getAliasIndex().get('flow')).toBe('%IW0')
})

it('returns the same reference until producer state changes', () => {
seedRemoteDevice(store, makeRemoteDevice('Dev1'))
store.getState().projectActions.addIOGroup('Dev1', makeIOGroup('g1', '3', 2))
const first = store.getState().projectActions.getAliasIndex()
// A POU edit must NOT invalidate the memo — the LSP calls this on every
// reconcile, i.e. on every keystroke in an ST editor.
seedPou(store, makePou('Prog', 'program', [locVar('x', '')]))
expect(store.getState().projectActions.getAliasIndex()).toBe(first)

// A producer change must.
const pointId = store.getState().project.data.remoteDevices![0].modbusTcpConfig!.ioGroups[0].ioPoints![0].id
store.getState().projectActions.updateIOPointAlias('Dev1', 'g1', pointId, 'flow')
const second = store.getState().projectActions.getAliasIndex()
expect(second).not.toBe(first)
expect(second.get('flow')).toBe('%IW0')
})
})

// -------------------------------------------------------------------------
// Defensive guard coverage — operations on servers missing expected configs
// -------------------------------------------------------------------------
Expand Down
Loading
Loading