diff --git a/src/frontend/components/_features/[workspace]/data-type/__tests__/code-view-toggle.test.tsx b/src/frontend/components/_features/[workspace]/data-type/__tests__/code-view-toggle.test.tsx
new file mode 100644
index 000000000..f6851127c
--- /dev/null
+++ b/src/frontend/components/_features/[workspace]/data-type/__tests__/code-view-toggle.test.tsx
@@ -0,0 +1,38 @@
+import { fireEvent, render, screen } from '@testing-library/react'
+
+// The code view pulls in Monaco, which cannot run in jsdom.
+vi.mock('@root/frontend/components/_organisms/variables-code-editor', () => ({
+ VariablesCodeEditor: () =>
,
+}))
+
+vi.mock('@root/frontend/utils/feature-flags', () => ({
+ isDataTypeFilesEnabled: () => true,
+}))
+
+import { useOpenPLCStore } from '@root/frontend/store'
+
+import { DataTypeEditor } from '../index'
+
+/** Reproduce what a go-to-definition redirect leaves behind. */
+function arriveFromGotoDefinition(name: string) {
+ const { editorActions } = useOpenPLCStore.getState()
+ editorActions.updateModelStructureForName(name, { display: 'code' })
+ editorActions.setEditorCursor(name, { lineNumber: 2, column: 3, offset: 0, target: 'data-type' })
+}
+
+describe('DataTypeEditor code view toggle', () => {
+ it('lets the user switch back to the table after a goto-definition jump', () => {
+ const created = useOpenPLCStore.getState().datatypeActions.create({ name: 'Motor', derivation: 'structure' })
+ expect(created.ok).toBe(true)
+
+ arriveFromGotoDefinition('Motor')
+ render()
+ expect(screen.getByTestId('variables-code-editor')).toBeTruthy()
+
+ fireEvent.click(screen.getByLabelText('Data type table visualization'))
+
+ // The jump's cursor is still on the model; it must not re-assert code
+ // mode and pin the tab there.
+ expect(screen.queryByTestId('variables-code-editor')).toBeNull()
+ })
+})
diff --git a/src/frontend/components/_features/[workspace]/data-type/index.tsx b/src/frontend/components/_features/[workspace]/data-type/index.tsx
index ae5a87ab9..2c6eabadf 100644
--- a/src/frontend/components/_features/[workspace]/data-type/index.tsx
+++ b/src/frontend/components/_features/[workspace]/data-type/index.tsx
@@ -1,9 +1,10 @@
-import { ComponentPropsWithoutRef, useEffect, useRef, useState } from 'react'
+import { ComponentPropsWithoutRef, useEffect, useMemo, useRef, useState } from 'react'
import type { PLCDataType } from '../../../../../middleware/shared/ports/types'
import { CodeIcon } from '../../../../assets/icons/interface/CodeIcon'
import { TableIcon } from '../../../../assets/icons/interface/TableIcon'
import { usePouSnapshot } from '../../../../hooks/use-pou-snapshot'
+import { dtViewUri } from '../../../../services/st-lsp/types'
import { useOpenPLCStore } from '../../../../store'
import { extractSearchQuery } from '../../../../store/slices/search/utils'
import { cn } from '../../../../utils/cn'
@@ -152,6 +153,29 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => {
commitCodeRef.current = commitCode
})
+ // Stable reference, or the child's cursor-jump effect re-fires every
+ // keystroke and re-selects the navigated line.
+ const codeCursorPosition = useMemo(
+ () =>
+ model?.cursorPosition?.target === 'data-type'
+ ? {
+ lineNumber: model.cursorPosition.lineNumber,
+ column: model.cursorPosition.column,
+ target: 'data-type' as const,
+ }
+ : undefined,
+ [model?.cursorPosition?.target, model?.cursorPosition?.lineNumber, model?.cursorPosition?.column],
+ )
+
+ // Goto-definition can land here while the tab is still in table mode.
+ // Keyed on the cursor alone: including `display` would re-fire on the
+ // user's own switch back to table and pin the tab in code mode.
+ useEffect(() => {
+ if (!codeCursorPosition || display === 'code') return
+ updateModelStructureForName(dataTypeName, { display: 'code', code: editorCode })
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [codeCursorPosition])
+
useEffect(() => {
if (display !== 'code') return
@@ -327,6 +351,8 @@ const DataTypeEditor = ({ dataTypeName, ...rest }: DatatypeEditorProps) => {
code={editorCode}
onCodeChange={setEditorCode}
shouldUseDarkMode={shouldUseDarkMode}
+ modelUri={dtViewUri(dataTypeName)}
+ cursorPosition={codeCursorPosition}
/>
{parseError && Error: {parseError}
}
diff --git a/src/frontend/components/_organisms/variables-code-editor/index.tsx b/src/frontend/components/_organisms/variables-code-editor/index.tsx
index 944cea274..c2d1daed6 100644
--- a/src/frontend/components/_organisms/variables-code-editor/index.tsx
+++ b/src/frontend/components/_organisms/variables-code-editor/index.tsx
@@ -41,6 +41,12 @@ interface VariablesCodeEditorProps {
* cross-routing.
*/
pouName?: string
+ /**
+ * Explicit Monaco model URI, for surfaces that route to the LSP but
+ * aren't POU variables (the data type `.dt` code view). Takes
+ * precedence over the URI derived from `pouName`.
+ */
+ modelUri?: string
/**
* Programmatic cursor jump (e.g. compile-error click → vars-text
* view, or Go to Definition redirect for a variable declaration).
@@ -54,7 +60,7 @@ interface VariablesCodeEditorProps {
* (it's meant for the body editor). Undefined or
* `target === 'variables'` is honoured here.
*/
- cursorPosition?: { lineNumber: number; column: number; target?: 'body' | 'variables' }
+ cursorPosition?: { lineNumber: number; column: number; target?: 'body' | 'variables' | 'data-type' }
}
const VariablesCodeEditor = ({
@@ -63,9 +69,11 @@ const VariablesCodeEditor = ({
shouldUseDarkMode,
cursorPosition,
pouName,
+ modelUri,
language = 'st',
readOnly = false,
}: VariablesCodeEditorProps) => {
+ const resolvedModelUri = modelUri ?? (pouName ? pouVarsUri(pouName) : undefined)
const editorRef = useRef(null)
const containerRef = useRef(null)
const [editorMounted, setEditorMounted] = useState(false)
@@ -146,7 +154,7 @@ const VariablesCodeEditor = ({
height='100%'
width='100%'
language={language}
- {...(pouName ? { path: pouVarsUri(pouName) } : {})}
+ {...(resolvedModelUri ? { path: resolvedModelUri } : {})}
defaultValue={''}
value={code}
onMount={handleEditorDidMount}
diff --git a/src/frontend/services/lsp-shared/__tests__/semantic-tokens-shift.test.ts b/src/frontend/services/lsp-shared/__tests__/semantic-tokens-shift.test.ts
new file mode 100644
index 000000000..447baa6bc
--- /dev/null
+++ b/src/frontend/services/lsp-shared/__tests__/semantic-tokens-shift.test.ts
@@ -0,0 +1,40 @@
+/**
+ * @jest-environment jsdom
+ */
+import { shiftSemanticTokensToBody } from '../internal/semantic-tokens-shift'
+
+// One token per line of a synthesised datatypes document:
+// 0 `TYPE` col 0
+// 1 ` Colors : (RED);` col 2
+// 2 ` Motor : STRUCT` col 2
+// 3 ` speed : INT;` col 4
+// 4 ` END_STRUCT;` col 2
+const AGGREGATE = [0, 0, 4, 0, 0, 1, 2, 6, 0, 0, 1, 2, 5, 0, 0, 1, 4, 5, 0, 0, 1, 2, 10, 0, 0]
+
+describe('shiftSemanticTokensToBody', () => {
+ it('drops tokens before the window and rebases the rest to line 0 by default', () => {
+ expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 1, 2))).toEqual([0, 2, 6, 0, 0])
+ })
+
+ it('rebases onto outputStartLine so a view can render its own frame above the window', () => {
+ // `Motor` occupies lines 2..4; its `.dt` view renders them under a
+ // local `TYPE` line, so they land on local lines 1..3.
+ expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 2, 5, 1))).toEqual([
+ 1, 2, 5, 0, 0, 1, 4, 5, 0, 0, 1, 2, 10, 0, 0,
+ ])
+ })
+
+ it('never emits the line above the window, whose columns can overrun a short frame line', () => {
+ const out = Array.from(shiftSemanticTokensToBody(AGGREGATE, 2, 5, 1))
+ // The `Colors` token (length 6) would not fit on the 4-character `TYPE` line.
+ expect(out).not.toContain(6)
+ })
+
+ it('keeps everything from startLine when no end is given', () => {
+ expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 3))).toEqual([0, 4, 5, 0, 0, 1, 2, 10, 0, 0])
+ })
+
+ it('returns an empty stream when the window selects nothing', () => {
+ expect(Array.from(shiftSemanticTokensToBody(AGGREGATE, 9, 12))).toEqual([])
+ })
+})
diff --git a/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts b/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts
index da257c4ca..2b4e042e3 100644
--- a/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts
+++ b/src/frontend/services/lsp-shared/internal/semantic-tokens-shift.ts
@@ -7,10 +7,15 @@
* 1. Decode deltas to absolute (line, col) positions.
* 2. Keep tokens whose line is in `[startLine, endLineExclusive)`.
* Both ends are LSP coordinates.
- * 3. Subtract `startLine` from each surviving token's line so the
+ * 3. Rebase each surviving token onto `outputStartLine` so the
* output is Monaco-relative.
* 4. Re-encode as a delta stream Monaco can consume directly.
*
+ * `outputStartLine` exists so a view that renders its own framing
+ * above the window doesn't have to widen the window to compensate:
+ * doing that drags in the preceding line's tokens, whose columns can
+ * overrun the shorter frame line ("end character > line length").
+ *
* Used in two modes by the ST LSP today:
* - **Body view**: `startLine = bodyLineOffset` (preamble line count),
* `endLineExclusive = ∞` — drop the preamble, keep everything
@@ -29,6 +34,7 @@ export function shiftSemanticTokensToBody(
data: number[],
startLine: number,
endLineExclusive: number = Number.POSITIVE_INFINITY,
+ outputStartLine: number = 0,
): Uint32Array {
// Decode to absolute positions.
const abs: Array<{ line: number; col: number; len: number; type: number; mods: number }> = []
@@ -51,7 +57,7 @@ export function shiftSemanticTokensToBody(
for (const t of abs) {
if (t.line < startLine) continue
if (t.line >= endLineExclusive) continue
- const shiftedLine = t.line - startLine
+ const shiftedLine = t.line - startLine + outputStartLine
const dLine = shiftedLine - prevLine
const dStart = dLine === 0 ? t.col - prevCol : t.col
out.push(dLine, dStart, t.len, t.type, t.mods)
diff --git a/src/frontend/services/lsp-shared/semantic-tokens.ts b/src/frontend/services/lsp-shared/semantic-tokens.ts
index 987d13452..9690ff17a 100644
--- a/src/frontend/services/lsp-shared/semantic-tokens.ts
+++ b/src/frontend/services/lsp-shared/semantic-tokens.ts
@@ -39,12 +39,14 @@ export interface SemanticTokensRegistration extends monaco.IDisposable {
* default keeps everything from `[lineOffset, +∞)` — drop the
* preamble, keep the rest. ST's variables-text view overrides this
* to clip the end at the body line so only VAR-block tokens render.
+ * `outputStartLine` rebases the kept window for views that render
+ * their own framing above it (the data type `.dt` code view).
*/
export type ResolveSemanticTokensViewport = (
lspUri: string,
modelUri: string,
lineOffset: number,
-) => { startLine: number; endLineExclusive: number }
+) => { startLine: number; endLineExclusive: number; outputStartLine?: number }
const defaultViewport: ResolveSemanticTokensViewport = (_lspUri, _modelUri, lineOffset) => ({
startLine: lineOffset,
@@ -87,10 +89,10 @@ export function registerLspSemanticTokens(opts: RegisterLspSemanticTokensOptions
textDocument: { uri: lspUri },
})
if (!result) return null
- const { startLine, endLineExclusive } = resolveViewport(lspUri, modelUri, lineOffset)
+ const { startLine, endLineExclusive, outputStartLine } = resolveViewport(lspUri, modelUri, lineOffset)
return {
...(result.resultId ? { resultId: result.resultId } : {}),
- data: shiftSemanticTokensToBody(result.data, startLine, endLineExclusive),
+ data: shiftSemanticTokensToBody(result.data, startLine, endLineExclusive, outputStartLine),
}
},
releaseDocumentSemanticTokens() {
diff --git a/src/frontend/services/lsp-shared/start-language-service.ts b/src/frontend/services/lsp-shared/start-language-service.ts
index 66af71bf7..5d7a5ecd7 100644
--- a/src/frontend/services/lsp-shared/start-language-service.ts
+++ b/src/frontend/services/lsp-shared/start-language-service.ts
@@ -84,6 +84,14 @@ export interface LanguageService {
changeDocument(uri: string, content: string, version?: number): void
/** Send `textDocument/didClose`. */
closeDocument(uri: string): void
+ /**
+ * Ask Monaco to re-query semantic tokens for every model in this
+ * language. Needed when a model's tokens derive from a *different*
+ * document (the datatype `.dt` view reads the aggregate doc), where
+ * a change to that document leaves the model's text untouched and
+ * therefore triggers no re-query of its own.
+ */
+ refreshSemanticTokens(): void
/** Tear down providers + transport. */
dispose(): void
}
@@ -232,6 +240,7 @@ export function startLanguageService(opts: StartLanguageServiceOptions): Languag
openDocument: () => undefined,
changeDocument: () => undefined,
closeDocument: () => undefined,
+ refreshSemanticTokens: () => undefined,
dispose: () => undefined,
}
}
@@ -333,6 +342,10 @@ export function startLanguageService(opts: StartLanguageServiceOptions): Languag
return {
ready,
+ refreshSemanticTokens() {
+ semanticTokensRegistration?.refresh()
+ },
+
openDocument(uri, content) {
if (disposed) return
const existing = documents.get(uri)
diff --git a/src/frontend/services/st-lsp/__tests__/dtview-context.test.ts b/src/frontend/services/st-lsp/__tests__/dtview-context.test.ts
new file mode 100644
index 000000000..735cd1bbc
--- /dev/null
+++ b/src/frontend/services/st-lsp/__tests__/dtview-context.test.ts
@@ -0,0 +1,99 @@
+/**
+ * @jest-environment jsdom
+ */
+import type { Diagnostic } from 'vscode-languageserver-protocol'
+
+import type { PLCDataType } from '../../../../middleware/shared/ports/types'
+import { diagnosticsInSpan, dtViewLineOffset, dtViewSpan, dtViewWindow } from '../dtview-context'
+
+const enumType = (name: string): PLCDataType => ({
+ name,
+ derivation: 'enumerated',
+ values: [{ description: 'RED' }],
+ initialValue: 'RED',
+})
+
+const structType = (name: string, fields: string[]): PLCDataType => ({
+ name,
+ derivation: 'structure',
+ variable: fields.map((field) => ({
+ name: field,
+ type: { definition: 'base-type', value: 'INT' },
+ })),
+})
+
+// Aggregate document, LSP (0-indexed) lines:
+// 0 TYPE
+// 1 Colors : (RED) := RED;
+// 2 Motor : STRUCT
+// 3 speed : INT;
+// 4 END_STRUCT;
+// 5 END_TYPE
+const DATA_TYPES: PLCDataType[] = [enumType('Colors'), structType('Motor', ['speed'])]
+
+const diagnosticAt = (line: number): Diagnostic => ({
+ range: { start: { line, character: 0 }, end: { line, character: 4 } },
+ message: `line ${line}`,
+})
+
+describe('dtViewSpan', () => {
+ it('returns the entry span for a type in the document', () => {
+ expect(dtViewSpan(DATA_TYPES, 'Motor')).toEqual({ start: 2, length: 3 })
+ })
+
+ it('returns null for a name the document has no entry for', () => {
+ expect(dtViewSpan(DATA_TYPES, 'Missing')).toBeNull()
+ })
+
+ it('returns null for every name when the document is empty', () => {
+ expect(dtViewSpan([], 'Colors')).toBeNull()
+ })
+})
+
+describe('dtViewLineOffset', () => {
+ it('is zero for the first entry — both frames open with their own TYPE line', () => {
+ const span = dtViewSpan(DATA_TYPES, 'Colors')
+ expect(span && dtViewLineOffset(span)).toBe(0)
+ })
+
+ it('shifts a later entry by its distance down the document', () => {
+ const span = dtViewSpan(DATA_TYPES, 'Motor')
+ expect(span && dtViewLineOffset(span)).toBe(1)
+ })
+
+ it('shifts the last entry of a longer document', () => {
+ const dataTypes = [enumType('A'), enumType('B'), structType('C', ['x', 'y'])]
+ const span = dtViewSpan(dataTypes, 'C')
+ expect(span && dtViewLineOffset(span)).toBe(2)
+ })
+})
+
+describe('dtViewWindow', () => {
+ it('covers the entry lines only, never the frame line above them', () => {
+ const span = dtViewSpan(DATA_TYPES, 'Motor')
+ expect(span && dtViewWindow(span)).toEqual({ startLine: 2, endLineExclusive: 5 })
+ })
+
+ it('is a single line for a one-line entry', () => {
+ const span = dtViewSpan(DATA_TYPES, 'Colors')
+ expect(span && dtViewWindow(span)).toEqual({ startLine: 1, endLineExclusive: 2 })
+ })
+})
+
+describe('diagnosticsInSpan', () => {
+ it('keeps only what falls inside the entry', () => {
+ const span = dtViewSpan(DATA_TYPES, 'Motor')
+ const kept = span ? diagnosticsInSpan([diagnosticAt(1), diagnosticAt(3), diagnosticAt(5)], span) : []
+ expect(kept.map((d) => d.message)).toEqual(['line 3'])
+ })
+
+ it('excludes the line directly above the entry — that belongs to the previous type', () => {
+ const span = dtViewSpan(DATA_TYPES, 'Motor')
+ expect(span && diagnosticsInSpan([diagnosticAt(1)], span)).toEqual([])
+ })
+
+ it('returns nothing when the entry is clean', () => {
+ const span = dtViewSpan(DATA_TYPES, 'Colors')
+ expect(span && diagnosticsInSpan([diagnosticAt(3)], span)).toEqual([])
+ })
+})
diff --git a/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts b/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts
index 4d06cbd12..0dd2b0aa5 100644
--- a/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts
+++ b/src/frontend/services/st-lsp/__tests__/goto-definition-redirect.test.ts
@@ -1,8 +1,9 @@
/**
* @jest-environment jsdom
*/
-import type { PLCPou } from '../../../../middleware/shared/ports/types'
+import type { PLCDataType, PLCPou } from '../../../../middleware/shared/ports/types'
import { openPLCStoreBase } from '../../../store'
+import * as featureFlags from '../../../utils/feature-flags'
import { setBodyLineOffset } from '../../lsp-shared/body-offsets'
import { redirectDefinitionToStore } from '../goto-definition-redirect'
@@ -62,6 +63,87 @@ describe('redirectDefinitionToStore', () => {
).toBe(false)
})
+ describe('datatypes URI with data types present', () => {
+ // Aggregate doc: line 0 `TYPE`, 1 `Colors : (...)`, then Motor's
+ // STRUCT spans 2..4 (declaration, one field, END_STRUCT).
+ const dataTypes: PLCDataType[] = [
+ { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }], initialValue: '' },
+ {
+ name: 'Motor',
+ derivation: 'structure',
+ variable: [{ name: 'speed', type: { definition: 'base-type', value: 'INT' } }],
+ },
+ ]
+
+ beforeEach(() => {
+ setProjectPous([])
+ openPLCStoreBase.setState((s) => ({
+ ...s,
+ project: { ...s.project, data: { ...s.project.data, dataTypes } },
+ }))
+ jest.spyOn(featureFlags, 'isDataTypeFilesEnabled').mockReturnValue(true)
+ })
+
+ afterEach(() => {
+ jest.restoreAllMocks()
+ })
+
+ it('opens the owning type in code mode with the cursor on its declaration line', () => {
+ expect(
+ redirectDefinitionToStore({
+ uri: 'inmemory://datatypes/__project__.st',
+ range: { start: { line: 1, character: 2 }, end: { line: 1, character: 8 } },
+ }),
+ ).toBe(true)
+
+ const state = openPLCStoreBase.getState()
+ expect(state.selectedTab).toBe('Colors')
+ // The active editor holds the fresh model — `updateModelStructureForName`
+ // writes there when the name matches, leaving `editors[]` behind.
+ const model = state.editor
+ expect(model.type === 'plc-datatype' && model.structure.display).toBe('code')
+ // Entry line 0 sits below the view's own `TYPE` frame → Monaco line 2.
+ expect(model.cursorPosition).toEqual({ lineNumber: 2, column: 3, offset: 0, target: 'data-type' })
+ })
+
+ it('lands on a struct field line inside the owning type', () => {
+ // Aggregate line 3 = Motor's `speed` field (entry starts at 2).
+ expect(
+ redirectDefinitionToStore({
+ uri: 'inmemory://datatypes/__project__.st',
+ range: { start: { line: 3, character: 4 }, end: { line: 3, character: 9 } },
+ }),
+ ).toBe(true)
+
+ const state = openPLCStoreBase.getState()
+ expect(state.selectedTab).toBe('Motor')
+ expect(state.editor.cursorPosition?.lineNumber).toBe(3)
+ })
+
+ it('returns false for the END_TYPE framing line past the last entry', () => {
+ expect(
+ redirectDefinitionToStore({
+ uri: 'inmemory://datatypes/__project__.st',
+ range: { start: { line: 5, character: 0 }, end: { line: 5, character: 0 } },
+ }),
+ ).toBe(false)
+ })
+
+ it('opens the form tab without a cursor when the code view is not built in', () => {
+ jest.spyOn(featureFlags, 'isDataTypeFilesEnabled').mockReturnValue(false)
+ expect(
+ redirectDefinitionToStore({
+ uri: 'inmemory://datatypes/__project__.st',
+ range: { start: { line: 1, character: 0 }, end: { line: 1, character: 0 } },
+ }),
+ ).toBe(true)
+
+ const model = openPLCStoreBase.getState().editor
+ expect(model.type === 'plc-datatype' && model.structure.display).toBe('table')
+ expect(model.cursorPosition).toBeUndefined()
+ })
+ })
+
it('returns false when the target POU does not exist in the project', () => {
setProjectPous([])
expect(
diff --git a/src/frontend/services/st-lsp/__tests__/types.test.ts b/src/frontend/services/st-lsp/__tests__/types.test.ts
index 63ad17e47..597dd5fdd 100644
--- a/src/frontend/services/st-lsp/__tests__/types.test.ts
+++ b/src/frontend/services/st-lsp/__tests__/types.test.ts
@@ -1,4 +1,13 @@
-import { parsePouUri, parsePouVarsUri, POU_DECLARATION_LINE_COUNT, pouUri, pouVarsUri, stubUri } from '../types'
+import {
+ dtViewUri,
+ parseDtViewUri,
+ parsePouUri,
+ parsePouVarsUri,
+ POU_DECLARATION_LINE_COUNT,
+ pouUri,
+ pouVarsUri,
+ stubUri,
+} from '../types'
describe('pouUri / stubUri', () => {
it('produces well-formed in-memory URIs', () => {
@@ -71,3 +80,37 @@ describe('POU_DECLARATION_LINE_COUNT', () => {
expect(POU_DECLARATION_LINE_COUNT).toBe(1)
})
})
+
+describe('dtViewUri / parseDtViewUri', () => {
+ it('round-trips a data type name, encoding included', () => {
+ expect(dtViewUri('Motor')).toBe('inmemory://dtview/Motor.dt')
+ expect(parseDtViewUri(dtViewUri('My Type'))).toBe('My Type')
+ })
+
+ it('returns null for the other ST URI shapes', () => {
+ expect(parseDtViewUri(pouUri('Motor'))).toBeNull()
+ expect(parseDtViewUri(pouVarsUri('Motor'))).toBeNull()
+ expect(parseDtViewUri('inmemory://datatypes/__project__.st')).toBeNull()
+ })
+
+ it('does not collide with the pouvars parser', () => {
+ expect(parsePouVarsUri(dtViewUri('Motor'))).toBeNull()
+ })
+})
+
+describe('malformed percent encoding', () => {
+ // These parsers run on every model URI the providers see, so a throw
+ // here would take hover / completion down for that model.
+ it('returns null instead of throwing, for every synthetic URI shape', () => {
+ expect(parsePouUri('inmemory://pou/%ZZ.st')).toBeNull()
+ expect(parsePouUri('inmemory://stub/%ZZ.st')).toBeNull()
+ expect(parsePouVarsUri('inmemory://pouvars/%ZZ.st')).toBeNull()
+ expect(parseDtViewUri('inmemory://dtview/%ZZ.dt')).toBeNull()
+ })
+
+ it('still decodes well-formed encodings', () => {
+ expect(parsePouUri(pouUri('My POU'))?.name).toBe('My POU')
+ expect(parsePouVarsUri(pouVarsUri('My POU'))).toBe('My POU')
+ expect(parseDtViewUri(dtViewUri('My Type'))).toBe('My Type')
+ })
+})
diff --git a/src/frontend/services/st-lsp/dtview-context.ts b/src/frontend/services/st-lsp/dtview-context.ts
new file mode 100644
index 000000000..22ccfbd10
--- /dev/null
+++ b/src/frontend/services/st-lsp/dtview-context.ts
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// Copyright (C) 2025 Autonomy / OpenPLC Project
+/**
+ * Coordinate translation between a `.dt` code view and the aggregate
+ * datatypes document.
+ *
+ * A `.dt` view renders one type under its own `TYPE` frame line, while
+ * strucpp only ever sees `DATA_TYPES_URI` — every type in one document,
+ * under one frame. So the two frames differ by the type's position in
+ * the aggregate, and every request crossing the seam has to be shifted
+ * by it.
+ *
+ * These helpers take `dataTypes` rather than reading the store, so the
+ * arithmetic is testable on its own — the seam is where the token and
+ * marker defects in DOPE-537 came from.
+ */
+
+import type { Diagnostic } from 'vscode-languageserver-protocol'
+
+import type { PLCDataType } from '../../../middleware/shared/ports/types'
+import { type DataTypeLineSpan, dataTypeLineSpans } from '../../utils/PLC/data-type-serializer'
+import { DT_VIEW_FRAME_LINE_COUNT } from './types'
+
+/** The aggregate document's line span for `dtName`, or null if it has none. */
+export function dtViewSpan(dataTypes: PLCDataType[], dtName: string): DataTypeLineSpan | null {
+ return dataTypeLineSpans(dataTypes).get(dtName) ?? null
+}
+
+/**
+ * Lines to add to a `.dt` view position to reach the aggregate document.
+ * Both frames open with a `TYPE` line, so the shift is the entry's start
+ * minus that frame — 0 for the first type.
+ */
+export function dtViewLineOffset(span: DataTypeLineSpan): number {
+ return span.start - DT_VIEW_FRAME_LINE_COUNT
+}
+
+/**
+ * Aggregate-document line window backing the view. Holds the entry's own
+ * lines only — widening it to cover the view's `TYPE` frame would pull in
+ * the previous entry's last line, whose columns overrun that 4-character
+ * frame line.
+ */
+export function dtViewWindow(span: DataTypeLineSpan): { startLine: number; endLineExclusive: number } {
+ return { startLine: span.start, endLineExclusive: span.start + span.length }
+}
+
+/** The published diagnostics that fall inside the entry's own lines. */
+export function diagnosticsInSpan(diagnostics: Diagnostic[], span: DataTypeLineSpan): Diagnostic[] {
+ const { startLine, endLineExclusive } = dtViewWindow(span)
+ return diagnostics.filter((d) => d.range.start.line >= startLine && d.range.start.line < endLineExclusive)
+}
diff --git a/src/frontend/services/st-lsp/goto-definition-redirect.ts b/src/frontend/services/st-lsp/goto-definition-redirect.ts
index 7697f2e9e..675d253fc 100644
--- a/src/frontend/services/st-lsp/goto-definition-redirect.ts
+++ b/src/frontend/services/st-lsp/goto-definition-redirect.ts
@@ -39,10 +39,17 @@ import type { PLCDataType } from '../../../middleware/shared/ports/types'
import { sanitizeAxisName, softMotionAxisNames } from '../../../middleware/shared/utils/ethercat'
import { openPLCStoreBase } from '../../store'
import { CreateEditorObjectFromTab } from '../../store/slices/tabs/utils'
-import { serializeDataTypesToLines } from '../../utils/PLC/data-type-serializer'
+import { isDataTypeFilesEnabled } from '../../utils/feature-flags'
+import { dataTypeLineSpans } from '../../utils/PLC/data-type-serializer'
import { getBodyLineOffset } from '../lsp-shared/body-offsets'
import { normaliseLocation, routeToPou, routeToPouBody, routeToPouPreamble } from '../lsp-shared/definition-redirect'
-import { DATA_TYPES_URI, parsePouUri, RESOURCE_GLOBALS_URI, SOFTMOTION_GLOBALS_URI } from './types'
+import {
+ DATA_TYPES_URI,
+ DT_VIEW_FRAME_LINE_COUNT,
+ parsePouUri,
+ RESOURCE_GLOBALS_URI,
+ SOFTMOTION_GLOBALS_URI,
+} from './types'
/**
* Map an LSP line in the synthesised datatypes document to the
@@ -56,18 +63,18 @@ import { DATA_TYPES_URI, parsePouUri, RESOURCE_GLOBALS_URI, SOFTMOTION_GLOBALS_U
* line counts that drift the moment a new field separator or
* derivation lands on disk.
*/
-function findDataTypeAtLine(lspLine: number, dataTypes: PLCDataType[]): PLCDataType | null {
+function findDataTypeAtLine(
+ lspLine: number,
+ dataTypes: PLCDataType[],
+): { dataType: PLCDataType; lineInEntry: number } | null {
// Synthesised doc: line 0 is `TYPE`, entries start at line 1.
if (lspLine < 1) return null
- const entries = serializeDataTypesToLines(dataTypes)
const byName = new Map(dataTypes.map((dt) => [dt.name, dt]))
- let cursor = 1
- for (const entry of entries) {
- const span = entry.lines.length
- if (lspLine >= cursor && lspLine < cursor + span) {
- return byName.get(entry.name) ?? null
+ for (const [name, span] of dataTypeLineSpans(dataTypes)) {
+ if (lspLine >= span.start && lspLine < span.start + span.length) {
+ const dataType = byName.get(name)
+ return dataType ? { dataType, lineInEntry: lspLine - span.start } : null
}
- cursor += span
}
return null
}
@@ -103,6 +110,27 @@ function openDataTypeEditor(dataType: PLCDataType): boolean {
return true
}
+/**
+ * Open the type's tab in code mode at a Monaco position in its `.dt`
+ * view. Falls back to the form tab when the code view isn't built into
+ * this release.
+ */
+function routeToDataTypeCodeView(dataType: PLCDataType, monacoLine: number, monacoColumn: number): boolean {
+ if (!openDataTypeEditor(dataType)) return false
+ if (!isDataTypeFilesEnabled()) return true
+ const {
+ editorActions: { setEditorCursor, updateModelStructureForName },
+ } = openPLCStoreBase.getState()
+ updateModelStructureForName(dataType.name, { display: 'code' })
+ setEditorCursor(dataType.name, {
+ lineNumber: monacoLine,
+ column: monacoColumn,
+ offset: 0,
+ target: 'data-type',
+ })
+ return true
+}
+
/**
* Open the EtherCAT device (drive) editor for `deviceId` on `busName`, mirroring
* the project-tree click path. Used to redirect go-to-definition on a SoftMotion
@@ -206,10 +234,15 @@ export function redirectDefinitionToStore(loc: Location | LocationLink): boolean
// branch the redirect would dead-end silently.
if (target.uri === DATA_TYPES_URI) {
const dataTypes = openPLCStoreBase.getState().project.data.dataTypes
- const dt = findDataTypeAtLine(target.lineLsp, dataTypes)
- if (!dt) return false
- openDataTypeEditor(dt)
- return true
+ const hit = findDataTypeAtLine(target.lineLsp, dataTypes)
+ if (!hit) return false
+ // Entry-relative line → `.dt` view line (its own `TYPE` frame sits
+ // above the entry) → Monaco's 1-indexed frame.
+ return routeToDataTypeCodeView(
+ hit.dataType,
+ hit.lineInEntry + DT_VIEW_FRAME_LINE_COUNT + 1,
+ target.characterLsp + 1,
+ )
}
const parsed = parsePouUri(target.uri)
diff --git a/src/frontend/services/st-lsp/index.ts b/src/frontend/services/st-lsp/index.ts
index dd74eb89f..0c3ba077d 100644
--- a/src/frontend/services/st-lsp/index.ts
+++ b/src/frontend/services/st-lsp/index.ts
@@ -25,6 +25,7 @@
* disposed only at shutdown.
*/
+import type * as monaco from 'monaco-editor'
import {
type CompletionItem as LspCompletionItem,
type CompletionList,
@@ -35,6 +36,8 @@ import {
} from 'vscode-languageserver-protocol'
import { openPLCStoreBase } from '../../store'
+import { isDataTypeFilesEnabled } from '../../utils/feature-flags'
+import { dataTypeLineSpans, serializeDataTypeToText } from '../../utils/PLC/data-type-serializer'
import { serializePouScopeForQuery } from '../../utils/PLC/pou-signature-serializer'
import {
getBodyLineOffset,
@@ -45,10 +48,15 @@ import {
suppressNoDefinitionFound,
} from '../lsp-shared'
import { parseScopedCompletionType } from './completion-type'
+import { diagnosticsInSpan, dtViewLineOffset, dtViewSpan, dtViewWindow } from './dtview-context'
import { redirectDefinitionToStore } from './goto-definition-redirect'
import { redirectToGraphicalPou } from './graphical-redirect'
import { registerScopedQueryApi, type ScopedCompletionItem } from './scoped-query'
import {
+ DATA_TYPES_URI,
+ DT_VIEW_FRAME_LINE_COUNT,
+ dtViewUri,
+ parseDtViewUri,
parsePouUri,
parsePouVarsUri,
POU_DECLARATION_LINE_COUNT,
@@ -85,6 +93,9 @@ interface LoadStlibBufferParams {
* that's `pou://`; for graphical/hybrid POUs it's `stub://`.
* Either way the declaration is a single line at LSP index 0,
* so the offset is a constant 1.
+ * - `dtview://.dt` (per-type code view): remap to the
+ * aggregate datatypes document. Both frames open with a `TYPE`
+ * line, so the shift is the type's span start minus that frame.
* - Anything else: pass through unchanged.
*/
function resolveStLspContext(modelUri: string): LspContext {
@@ -95,9 +106,51 @@ function resolveStLspContext(modelUri: string): LspContext {
const lspUri = isStLanguage ? pouUri(varsPou) : stubUri(varsPou)
return { lspUri, lineOffset: POU_DECLARATION_LINE_COUNT }
}
+ const dtName = parseDtViewUri(modelUri)
+ if (dtName !== null) {
+ const span = dtViewSpan(openPLCStoreBase.getState().project.data.dataTypes, dtName)
+ // A name absent from the document (unparseable `.dt` file) has no
+ // span to shift by. Pass the view's own URI through: the worker
+ // never indexed it, so every provider answers nothing rather than
+ // answering for whichever type happens to be first.
+ if (!span) return { lspUri: modelUri, lineOffset: 0 }
+ return { lspUri: DATA_TYPES_URI, lineOffset: dtViewLineOffset(span) }
+ }
return { lspUri: modelUri, lineOffset: getBodyLineOffset(modelUri) }
}
+/**
+ * True while a `.dt` model's text still matches what the store would
+ * serialise for that type. An uncommitted edit breaks the match, and
+ * tokens resolved against the aggregate document would then be painted
+ * onto text they don't describe — wrong colours, and columns past the
+ * end of shorter lines.
+ */
+function dtViewMatchesStore(dtName: string, monacoApi: typeof monaco): boolean {
+ const dataType = openPLCStoreBase.getState().project.data.dataTypes.find((d) => d.name === dtName)
+ if (!dataType) return false
+ const model = monacoApi.editor.getModels().find((m) => m.uri.toString() === dtViewUri(dtName))
+ if (!model) return false
+ return model.getValue() === serializeDataTypeToText(dataType)
+}
+
+let lastDataTypeDiagnostics: Diagnostic[] = []
+
+/** Fan the aggregate doc's diagnostics out to every mounted `.dt` model. */
+function applyDataTypeDiagnostics(monacoApi: typeof monaco, markerOwner: string, defaultSource: string): void {
+ for (const [name, span] of dataTypeLineSpans(openPLCStoreBase.getState().project.data.dataTypes)) {
+ const model = monacoApi.editor.getModels().find((m) => m.uri.toString() === dtViewUri(name))
+ if (!model) continue
+ monacoApi.editor.setModelMarkers(
+ model,
+ markerOwner,
+ diagnosticsInSpan(lastDataTypeDiagnostics, span).map((d) =>
+ lspDiagnosticToMonaco(d, monacoApi, dtViewLineOffset(span), defaultSource),
+ ),
+ )
+ }
+}
+
export function startStLsp(opts: StLspStartOptions): StLspService {
const { stlibSource, monaco: monacoApi, workerUrlOverride, onCrash } = opts
@@ -151,6 +204,16 @@ export function startStLsp(opts: StLspStartOptions): StLspService {
// VAR-block region; body editors keep everything from the
// body line onwards.
resolveSemanticTokensViewport: (lspUri, modelUri, lineOffset) => {
+ const dtName = parseDtViewUri(modelUri)
+ if (dtName !== null) {
+ const span = dtViewSpan(openPLCStoreBase.getState().project.data.dataTypes, dtName)
+ // Empty window while the buffer is uncommitted: no colours beats
+ // colours describing the previous text.
+ if (!span || !monacoApi || !dtViewMatchesStore(dtName, monacoApi)) {
+ return { startLine: 0, endLineExclusive: 0 }
+ }
+ return { ...dtViewWindow(span), outputStartLine: DT_VIEW_FRAME_LINE_COUNT }
+ }
const isVarsView = parsePouVarsUri(modelUri) !== null
return {
startLine: lineOffset,
@@ -162,6 +225,16 @@ export function startStLsp(opts: StLspStartOptions): StLspService {
markerOwner: MARKER_OWNER,
diagnosticSource: DIAGNOSTIC_SOURCE,
diagnosticsMirror: (params, ctx) => {
+ // Same trick for the aggregate datatypes doc: strucpp publishes
+ // against one URI, but each type renders in its own `.dt` view.
+ if (params.uri === DATA_TYPES_URI) {
+ // Replayed whenever a `.dt` model mounts later — the mirror is
+ // event-driven, so a model created after the last publish would
+ // otherwise show no markers at all.
+ lastDataTypeDiagnostics = params.diagnostics
+ applyDataTypeDiagnostics(ctx.monacoApi, ctx.markerOwner, ctx.defaultSource)
+ return
+ }
// Mirror VAR-block diagnostics onto the variables-text editor
// for the same POU (if mounted). The variables editor uses a
// separate Monaco model under `pouvars://.st`; strucpp
@@ -194,6 +267,32 @@ export function startStLsp(opts: StLspStartOptions): StLspService {
...(onCrash ? { onCrash } : {}),
})
+ // A `.dt` view's colours and markers come from the aggregate document,
+ // so a change there leaves the model's own text untouched and Monaco
+ // never re-queries on its own. Re-drive both from the store instead.
+ const dtViewSyncDisposables: Array<() => void> = []
+ if (monacoApi && isDataTypeFilesEnabled()) {
+ const api = monacoApi
+ const hasDtViewModel = () => api.editor.getModels().some((m) => parseDtViewUri(m.uri.toString()) !== null)
+ dtViewSyncDisposables.push(
+ openPLCStoreBase.subscribe(
+ (state) => state.project.data.dataTypes,
+ () => {
+ // `refresh()` re-tokenises every ST model in the language, so it
+ // must not fire for a datatype edit made with no `.dt` view open.
+ if (!hasDtViewModel()) return
+ sharedService.refreshSemanticTokens()
+ applyDataTypeDiagnostics(api, MARKER_OWNER, DIAGNOSTIC_SOURCE)
+ },
+ ),
+ )
+ const onModelAdded = api.editor.onDidCreateModel((model) => {
+ if (parseDtViewUri(model.uri.toString()) === null) return
+ applyDataTypeDiagnostics(api, MARKER_OWNER, DIAGNOSTIC_SOURCE)
+ })
+ dtViewSyncDisposables.push(() => onModelAdded.dispose())
+ }
+
// ---------------------------------------------------------------------------
// Scoped completion for the graphical (LD/FBD) editors.
//
@@ -435,6 +534,9 @@ export function startStLsp(opts: StLspStartOptions): StLspService {
dispose() {
registerScopedQueryApi(null)
+ for (const off of dtViewSyncDisposables) off()
+ dtViewSyncDisposables.length = 0
+ lastDataTypeDiagnostics = []
sharedService.dispose()
},
}
diff --git a/src/frontend/services/st-lsp/types.ts b/src/frontend/services/st-lsp/types.ts
index ee506b6d5..d667f8372 100644
--- a/src/frontend/services/st-lsp/types.ts
+++ b/src/frontend/services/st-lsp/types.ts
@@ -31,6 +31,13 @@ export const STUB_URI_AUTHORITY = 'stub'
*/
export const POUVARS_URI_AUTHORITY = 'pouvars'
+/**
+ * URI scheme for the per-type `.dt` code view. Like `pouvars://`, the
+ * LSP never indexes it — requests remap onto `DATA_TYPES_URI` with the
+ * type's line span as the offset.
+ */
+export const DTVIEW_URI_AUTHORITY = 'dtview'
+
/**
* URI for the synthesized `TYPE…END_TYPE` document carrying every
* user-defined `PLCDataType` (structures, enumerations, arrays).
@@ -139,7 +146,19 @@ export function pouVarsUri(name: string): string {
export function parsePouVarsUri(uri: string): string | null {
const match = new RegExp(`^${POU_URI_SCHEME}://${POUVARS_URI_AUTHORITY}/(.+)\\.st$`).exec(uri)
if (!match) return null
- return decodeURIComponent(match[1])
+ return decodeUriSegment(match[1])
+}
+
+/** Make a synthetic in-memory URI for a data type's `.dt` code view. */
+export function dtViewUri(name: string): string {
+ return `${POU_URI_SCHEME}://${DTVIEW_URI_AUTHORITY}/${encodeURIComponent(name)}.dt`
+}
+
+/** If `uri` is a `dtview://` URI, return the data type name; otherwise null. */
+export function parseDtViewUri(uri: string): string | null {
+ const match = new RegExp(`^${POU_URI_SCHEME}://${DTVIEW_URI_AUTHORITY}/(.+)\\.dt$`).exec(uri)
+ if (!match) return null
+ return decodeUriSegment(match[1])
}
/**
@@ -151,6 +170,28 @@ export function parsePouVarsUri(uri: string): string | null {
*/
export const POU_DECLARATION_LINE_COUNT = 1
+/**
+ * Lines the `.dt` code view renders before the type's own declaration —
+ * its local `TYPE` frame line. The aggregate document has the same
+ * frame, so a type's shift between the two is `span.start - DT_VIEW_FRAME_LINE_COUNT`.
+ */
+export const DT_VIEW_FRAME_LINE_COUNT = 1
+
+/**
+ * Decode a name segment out of a synthetic URI, or `null` when the
+ * encoding is malformed. These parsers run on every model URI the LSP
+ * providers see, so a bare `decodeURIComponent` would turn a stray
+ * `%ZZ` into a thrown `URIError` and take hover / completion down with
+ * it for that model.
+ */
+function decodeUriSegment(segment: string): string | null {
+ try {
+ return decodeURIComponent(segment)
+ } catch {
+ return null
+ }
+}
+
/**
* Returns the POU name encoded in a URI minted by `pouUri` or
* `stubUri`, or `null` if the URI doesn't match one of those
@@ -160,8 +201,7 @@ export const POU_DECLARATION_LINE_COUNT = 1
export function parsePouUri(uri: string): { kind: 'pou' | 'stub'; name: string } | null {
const match = new RegExp(`^${POU_URI_SCHEME}://(${POU_URI_AUTHORITY}|${STUB_URI_AUTHORITY})/(.+)\\.st$`).exec(uri)
if (!match) return null
- return {
- kind: match[1] === POU_URI_AUTHORITY ? 'pou' : 'stub',
- name: decodeURIComponent(match[2]),
- }
+ const name = decodeUriSegment(match[2])
+ if (name === null) return null
+ return { kind: match[1] === POU_URI_AUTHORITY ? 'pou' : 'stub', name }
}
diff --git a/src/frontend/store/slices/editor/types.ts b/src/frontend/store/slices/editor/types.ts
index 39786825c..7857366bd 100644
--- a/src/frontend/store/slices/editor/types.ts
+++ b/src/frontend/store/slices/editor/types.ts
@@ -76,13 +76,15 @@ export type CursorPosition = {
* editor. Triggers a forced switch to text mode if the panel
* is currently in table mode, and the body editor ignores
* positions tagged this way.
+ * - `data-type` — targets a data type's `.dt` code view, with the
+ * same forced switch out of table mode.
*
* Used by Go to Definition redirects: when the LSP points at a
* variable declaration (synthesized header line), we surface that
* line inside the variables panel instead of clamping the cursor
* to the body's line 1.
*/
- target?: 'body' | 'variables'
+ target?: 'body' | 'variables' | 'data-type'
}
// ---------------------------------------------------------------------------
diff --git a/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts b/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts
index 2010918ed..898e8f847 100644
--- a/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts
+++ b/src/frontend/utils/PLC/__tests__/data-type-serializer.test.ts
@@ -11,7 +11,12 @@
* one of these up.
*/
import type { PLCDataType } from '../../../../middleware/shared/ports/types'
-import { serializeDataTypesToLines, serializeDataTypesToST, serializeDataTypeToText } from '../data-type-serializer'
+import {
+ dataTypeLineSpans,
+ serializeDataTypesToLines,
+ serializeDataTypesToST,
+ serializeDataTypeToText,
+} from '../data-type-serializer'
const enumerated = (name: string, values: string[], initialValue?: string): PLCDataType => ({
name,
@@ -255,3 +260,50 @@ describe('serializeDataTypesToLines', () => {
expect(reconstructed).toBe(flat)
})
})
+
+describe('dataTypeLineSpans', () => {
+ it('places entries after the TYPE frame and accumulates multi-line spans', () => {
+ const spans = dataTypeLineSpans([
+ { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }], initialValue: '' },
+ {
+ name: 'Motor',
+ derivation: 'structure',
+ variable: [
+ { name: 'speed', type: { definition: 'base-type', value: 'INT' } },
+ { name: 'torque', type: { definition: 'base-type', value: 'INT' } },
+ ],
+ },
+ {
+ name: 'Buffer',
+ derivation: 'array',
+ dimensions: [{ dimension: '0..9' }],
+ baseType: { definition: 'base-type', value: 'INT' },
+ initialValue: '',
+ },
+ ])
+
+ // Line 0 is `TYPE`; the struct occupies declaration + 2 fields + END_STRUCT.
+ expect(spans.get('Colors')).toEqual({ start: 1, length: 1 })
+ expect(spans.get('Motor')).toEqual({ start: 2, length: 4 })
+ expect(spans.get('Buffer')).toEqual({ start: 6, length: 1 })
+ })
+
+ it('agrees with the rendered aggregate document', () => {
+ const dataTypes: PLCDataType[] = [
+ { name: 'Colors', derivation: 'enumerated', values: [{ description: 'RED' }], initialValue: '' },
+ {
+ name: 'Motor',
+ derivation: 'structure',
+ variable: [{ name: 'speed', type: { definition: 'base-type', value: 'INT' } }],
+ },
+ ]
+ const lines = serializeDataTypesToST(dataTypes).split('\n')
+ for (const [name, span] of dataTypeLineSpans(dataTypes)) {
+ expect(lines[span.start]).toContain(name)
+ }
+ })
+
+ it('is empty for no data types', () => {
+ expect(dataTypeLineSpans([]).size).toBe(0)
+ })
+})
diff --git a/src/frontend/utils/PLC/data-type-serializer.ts b/src/frontend/utils/PLC/data-type-serializer.ts
index 26b7c1170..c9ece49c0 100644
--- a/src/frontend/utils/PLC/data-type-serializer.ts
+++ b/src/frontend/utils/PLC/data-type-serializer.ts
@@ -111,6 +111,33 @@ export function serializeDataTypesToLines(dataTypes: PLCDataType[]): SerializedD
return out
}
+/** Where one data type's lines sit inside the aggregate `TYPE…END_TYPE` block. */
+export interface DataTypeLineSpan {
+ /** 0-indexed first line of the entry in the aggregate document. */
+ start: number
+ /** Line count of the entry. */
+ length: number
+}
+
+/**
+ * Line spans of every entry in the aggregate document, keyed by name.
+ * Line 0 is the `TYPE` frame, so entries start at 1.
+ *
+ * The per-type `.dt` code view renders the same lines under its own
+ * `TYPE…END_TYPE` frame, so `start - 1` is the shift between the two
+ * frames — that is what the LSP layer needs to talk to the aggregate
+ * document on a per-type buffer's behalf.
+ */
+export function dataTypeLineSpans(dataTypes: PLCDataType[]): Map {
+ const spans = new Map()
+ let start = 1
+ for (const entry of serializeDataTypesToLines(dataTypes)) {
+ spans.set(entry.name, { start, length: entry.lines.length })
+ start += entry.lines.length
+ }
+ return spans
+}
+
/**
* Serialise every entry in `dataTypes` to a single ST `TYPE` block.
* Returns `''` when there's nothing to emit — the LSP sync layer