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
22 changes: 17 additions & 5 deletions src/semantic/type-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -537,29 +537,41 @@ export function resolveFieldType(
return undefined;
}

/** `__VLA_<rank>D_<ElementType>`, the AST builder's name for an `ARRAY [*]`. */
const VLA_NAME = /^__VLA_\d+D_(.+)$/;

/**
* Resolve the element type of an array type.
* Handles __INLINE_ARRAY_* internal types and user-defined array TYPE definitions.
* Handles __INLINE_ARRAY_* and __VLA_*D_* internal types, user-defined array
* TYPE definitions, and aliases that eventually name one.
*/
export function resolveArrayElementType(
typeName: string,
ast: CompilationUnit,
depth = 0,
): string | undefined {
if (depth >= MAX_TYPE_ALIAS_DEPTH) return undefined;
const typeUpper = typeName.toUpperCase();

// Handle __INLINE_ARRAY_<ElementType> internal types
if (typeUpper.startsWith("__INLINE_ARRAY_")) {
return typeUpper.substring("__INLINE_ARRAY_".length);
}

const vla = VLA_NAME.exec(typeUpper);
if (vla) return vla[1];

// Check user-defined array type definitions
for (const td of ast.types) {
if (
td.name.toUpperCase() === typeUpper &&
td.definition.kind === "ArrayDefinition"
) {
if (td.name.toUpperCase() !== typeUpper) continue;
if (td.definition.kind === "ArrayDefinition") {
return td.definition.elementType.name.toUpperCase();
}
if (td.definition.kind === "TypeReference") {
// Alias — keep walking toward the underlying array, if any.
return resolveArrayElementType(td.definition.name, ast, depth + 1);
}
return undefined;
}

return undefined;
Expand Down
96 changes: 78 additions & 18 deletions vscode-extension/server/src/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ import type {
FunctionBlockType,
SymbolTables,
Scope,
CompilationUnit,
} from "strucpp";
import { ELEMENTARY_TYPES, typeName } from "strucpp";
import { ELEMENTARY_TYPES, resolveArrayElementType, typeName } from "strucpp";
import { getCursorContext } from "./cursor-context.js";
import { getScopeForContext } from "./resolve-symbol.js";
import { isTestFile, extractTestVarDeclarations } from "../../shared/test-utils.js";
Expand Down Expand Up @@ -217,30 +218,31 @@ function getDotAccessCompletions(
const scope = getScopeForContext(symbolTables, pouScope);
if (!scope) return [];

// Parse the chain: "a.b.c" → resolve segment by segment
const segments = prefixExpr.split(".");
const resolvedType = resolveChainType(segments, scope, symbolTables);
// Parse the chain: "a.b[0].c" → resolve segment by segment
const segments = parseChainSegments(prefixExpr);
const resolvedType = resolveChainType(segments, scope, symbolTables, analysis.ast);
if (resolvedType) return getMembersForType(resolvedType, symbolTables);

// For test files, try resolving via locally declared variable types
if (testSource) {
const testVars = extractTestVarDeclarations(stripCommentsAndStrings(testSource));
const varType = testVars.get(segments[0].toUpperCase());
const varType = testVars.get(segments[0].name.toUpperCase());
if (varType) {
// Walk remaining segments through type chain
let currentTypeName = varType;
for (let i = 1; i < segments.length; i++) {
const nextType = resolveMemberType(currentTypeName, segments[i], symbolTables);
if (!nextType) return [];
currentTypeName = nextType;
let currentTypeName = unwrapSubscripts(varType, segments[0].subscripts, analysis.ast);
for (let i = 1; i < segments.length && currentTypeName !== undefined; i++) {
const nextType = resolveMemberType(currentTypeName, segments[i].name, symbolTables);
if (nextType === undefined) return [];
currentTypeName = unwrapSubscripts(nextType, segments[i].subscripts, analysis.ast);
}
if (currentTypeName === undefined) return [];
const typeInfo = resolveTypeName(currentTypeName, symbolTables);
if (typeInfo) return getMembersForType(typeInfo, symbolTables);
}
}

// Fallback: first segment may be a type name (e.g., EnumType.MEMBER)
const typeInfo = resolveTypeName(segments[0], symbolTables);
const typeInfo = resolveTypeName(segments[0].name, symbolTables);
if (typeInfo) return getMembersForType(typeInfo, symbolTables);

return [];
Expand All @@ -251,30 +253,88 @@ interface ResolvedTypeInfo {
name: string;
}

/** One link of a dotted chain: an identifier plus however many subscripts follow it. */
interface ChainSegment {
name: string;
subscripts: number;
}

/**
* Split "a.b[0].c" into its links, counting the subscripts on each. Dots inside
* a subscript belong to the index expression (`arr[s.k]`), not to the chain, so
* the split only happens at bracket depth 0.
*/
function parseChainSegments(prefixExpr: string): ChainSegment[] {
const segments: ChainSegment[] = [];
let name = "";
let subscripts = 0;
let depth = 0;

for (const ch of prefixExpr) {
if (ch === "[") {
if (depth === 0) subscripts++;
depth++;
} else if (ch === "]") {
if (depth > 0) depth--;
} else if (depth === 0) {
if (ch === ".") {
segments.push({ name, subscripts });
name = "";
subscripts = 0;
} else {
name += ch;
}
}
}
segments.push({ name, subscripts });

return segments;
}

/** Peel one array level per subscript. Undefined once a step is not an array. */
function unwrapSubscripts(
typeName: string,
subscripts: number,
ast?: CompilationUnit,
): string | undefined {
if (subscripts === 0) return typeName;
if (!ast) return undefined;
let current: string | undefined = typeName;
for (let i = 0; i < subscripts && current !== undefined; i++) {
current = resolveArrayElementType(current, ast);
}
return current;
}

/**
* Resolve a dotted identifier chain to its final type.
* e.g., "player.position" → resolves player (Sprite FB) → position (Point struct)
*/
function resolveChainType(
segments: string[],
segments: ChainSegment[],
scope: Scope,
symbolTables: SymbolTables,
ast?: CompilationUnit,
): ResolvedTypeInfo | undefined {
if (segments.length === 0) return undefined;

// Resolve first segment via scope lookup
const firstSym = scope.lookup(segments[0]);
const firstSym = scope.lookup(segments[0].name);
if (!firstSym || firstSym.kind !== "variable") return undefined;

let currentTypeName = getVariableTypeName(firstSym as VariableSymbol);
if (!currentTypeName) return undefined;
const declaredType = getVariableTypeName(firstSym as VariableSymbol);
if (declaredType === undefined) return undefined;
let currentTypeName = unwrapSubscripts(declaredType, segments[0].subscripts, ast);
if (currentTypeName === undefined) return undefined;

// Walk remaining segments
for (let i = 1; i < segments.length; i++) {
const memberName = segments[i];
const memberName = segments[i].name;
const nextType = resolveMemberType(currentTypeName, memberName, symbolTables);
if (!nextType) return undefined;
currentTypeName = nextType;
if (nextType === undefined) return undefined;
const unwrapped = unwrapSubscripts(nextType, segments[i].subscripts, ast);
if (unwrapped === undefined) return undefined;
currentTypeName = unwrapped;
}

// Determine what kind of type this is
Expand Down
8 changes: 7 additions & 1 deletion vscode-extension/server/src/cursor-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,13 @@ export function getCursorContext(
const prefix = currentLine.substring(0, column - 1);

// Check dot-access: prefix ends with identifier chain + "."
const dotMatch = prefix.match(/([\w]+(?:\.[\w]+)*)\.\s*$/);
// A link may carry subscripts (`pts[i].`, `grid[i, j].`, `mat[i][j].`), whose
// index may itself be subscripted (`arr[idx[i]].`). The chain resolver unwraps
// one array level per bracket group.
const subscript = /\[(?:[^[\]]|\[[^[\]]*\])*\]/.source;
const dotMatch = prefix.match(
new RegExp(`([\\w]+(?:${subscript})*(?:\\.[\\w]+(?:${subscript})*)*)\\.\\s*$`),
);
if (dotMatch) {
return {
kind: "dot-access",
Expand Down
13 changes: 13 additions & 0 deletions vscode-extension/server/src/resolve-symbol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,9 @@ export function resolveSymbolAtPosition(

case "VarDeclaration": {
const vd = node as VarDeclaration;
// Field names are never defined as symbols; a lookup would answer
// with whatever global shares the name.
if (isStructField(ast, vd)) return { node, scope };
// VarDeclaration can declare multiple names; resolve the first one
// (exact name matching would need column-level checking in the names list)
if (vd.names.length > 0) {
Expand Down Expand Up @@ -307,6 +310,16 @@ export function lookupSymbolByName(
return null;
}

/** True when the declaration is a STRUCT field rather than a POU local or a global. */
function isStructField(ast: CompilationUnit, vd: VarDeclaration): boolean {
for (const type of ast.types) {
const definition = type.definition;
if (definition.kind !== "StructDefinition") continue;
if (definition.fields.includes(vd)) return true;
}
return false;
}

export function getScopeForContext(
symbolTables: NonNullable<AnalysisResult["symbolTables"]>,
scope: EnclosingScope,
Expand Down
133 changes: 133 additions & 0 deletions vscode-extension/tests/unit/completion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -408,3 +408,136 @@ END_PROGRAM
expect(labelled(items, "plain[")).toEqual([]);
});
});

describe("dot-access past an array subscript", () => {
const SOURCE = `TYPE
Point : STRUCT
x : REAL;
y : REAL;
END_STRUCT;
END_TYPE

TYPE
PointArray : ARRAY [0..9] OF Point;
END_TYPE

TYPE
AliasArr : PointArray;
END_TYPE

TYPE
Row : ARRAY [0..3] OF Point;
END_TYPE

TYPE
Idx : STRUCT
k : INT;
END_STRUCT;
END_TYPE

TYPE
Holder : STRUCT
pts : ARRAY [0..3] OF Point;
END_STRUCT;
END_TYPE

PROGRAM Main
VAR
inlineArr : ARRAY [0..9] OF Point;
named : PointArray;
aliased : AliasArr;
mat : ARRAY [0..3] OF Row;
grid : ARRAY [0..3, 0..3] OF Point;
reals : ARRAY [0..9] OF REAL;
indices : ARRAY [0..3] OF INT;
holder : Holder;
plain : Point;
s : Idx;
i : INT;
END_VAR
PROBE
END_PROGRAM
`;

/** Complete immediately after `expr`, which must end in a dot. */
const completeAfter = (expr: string): string[] => {
const source = SOURCE.replace(" PROBE", ` ${expr}`);
const analysis = analyze(source, { fileName: "a.st" });
const lines = source.split("\n");
const line = lines.findIndex((l) => l.includes(expr)) + 1;
const col = lines[line - 1].indexOf(expr) + expr.length + 1;
return upperLabels(getCompletions(analysis, "a.st", line, col, source));
};

it("offers the element type's fields for an inline array", () => {
expect(completeAfter("inlineArr[i].")).toEqual(["X", "Y"]);
});

it("offers the element type's fields for a named array type", () => {
expect(completeAfter("named[i].")).toEqual(["X", "Y"]);
});

it("offers the element type's fields for a multi-dimensional array", () => {
expect(completeAfter("grid[i, i].")).toEqual(["X", "Y"]);
});

it("offers the element type's fields for an array reached through a field", () => {
expect(completeAfter("holder.pts[i].")).toEqual(["X", "Y"]);
});

it("matches the non-array baseline", () => {
expect(completeAfter("inlineArr[i].")).toEqual(completeAfter("plain."));
});

it("offers nothing for an array of an elementary type", () => {
// Not the keyword fallback: the context is dot-access, REAL has no members.
expect(completeAfter("reals[i].")).toEqual([]);
});

it("follows a TYPE alias to the array it names", () => {
expect(completeAfter("aliased[i].")).toEqual(["X", "Y"]);
});

it("peels one level per bracket group on an array of arrays", () => {
expect(completeAfter("mat[i][i].")).toEqual(["X", "Y"]);
});

it("keeps a dot inside the subscript out of the chain", () => {
expect(completeAfter("inlineArr[s.k].")).toEqual(["X", "Y"]);
});

it("accepts a subscripted index expression", () => {
expect(completeAfter("inlineArr[indices[i]].")).toEqual(["X", "Y"]);
});
});

describe("dot-access past a variable-length array subscript", () => {
// `ARRAY [*]` reaches the AST as `__VLA_<rank>D_<Element>`, a different
// internal spelling from an inline array's `__INLINE_ARRAY_<Element>`.
const SOURCE = `TYPE
Point : STRUCT
x : REAL;
y : REAL;
END_STRUCT;
END_TYPE

FUNCTION_BLOCK Sum
VAR_IN_OUT
vals : ARRAY [*] OF Point;
END_VAR
VAR
i : INT;
END_VAR
PROBE
END_FUNCTION_BLOCK
`;

it("offers the element type's fields", () => {
const source = SOURCE.replace(" PROBE", " vals[i].");
const analysis = analyze(source, { fileName: "v.st" });
const lines = source.split("\n");
const line = lines.findIndex((l) => l.includes("vals[i].")) + 1;
const col = lines[line - 1].indexOf("vals[i].") + "vals[i].".length + 1;
expect(upperLabels(getCompletions(analysis, "v.st", line, col, source))).toEqual(["X", "Y"]);
});
});
Loading
Loading