471 lines
14 KiB
TypeScript
471 lines
14 KiB
TypeScript
import { KdlStructuredError } from "../../kdl/rpc.js";
|
|
import type { MotionSourceMap } from "../../kdl/types.js";
|
|
import type {
|
|
IoFlowInstruction,
|
|
IoReference,
|
|
IoWriteInstruction,
|
|
OperationActionInstruction,
|
|
PathEventInstruction,
|
|
PulseInstruction,
|
|
WaitInstruction
|
|
} from "../ir/index.js";
|
|
import { lexGrl, type GrlToken } from "../lexer/index.js";
|
|
import { parseGrlExpression } from "../parser/index.js";
|
|
import { evaluateNumberExpression } from "./constantExpression.js";
|
|
import type { GrlNumberLiteral } from "../ast/index.js";
|
|
|
|
export interface IoMap {
|
|
aliases?: Record<string, IoReference>;
|
|
allowedRanges?: Partial<Record<IoReference["domain"], { min: number; max: number }>>;
|
|
}
|
|
|
|
export function parseIoFlowStatements(tokens: GrlToken[], ioMap: IoMap = {}): IoFlowInstruction[] {
|
|
const parser = new IoStatementParser(tokens, ioMap);
|
|
return parser.parseAll();
|
|
}
|
|
|
|
export function compilePathEventIo(event: PathEventInstruction, ioMap: IoMap = {}): IoFlowInstruction[] {
|
|
const tokens = event.data?.tokens;
|
|
if (Array.isArray(tokens)) {
|
|
return parseIoFlowStatements(tokens as GrlToken[], ioMap);
|
|
}
|
|
const statement = event.data?.statement;
|
|
if (typeof statement !== "string") {
|
|
return [];
|
|
}
|
|
return compileStatementString(statement, event.sourceMap, ioMap);
|
|
}
|
|
|
|
export function compileOperationActionIo(
|
|
action: OperationActionInstruction,
|
|
ioMap: IoMap = {}
|
|
): IoFlowInstruction[] {
|
|
if (Array.isArray(action.tokens)) {
|
|
return parseIoFlowStatements(action.tokens as GrlToken[], ioMap);
|
|
}
|
|
return compileStatementString(action.statement, action.sourceMap, ioMap);
|
|
}
|
|
|
|
class IoStatementParser {
|
|
private current = 0;
|
|
|
|
constructor(
|
|
private readonly tokens: GrlToken[],
|
|
private readonly ioMap: IoMap
|
|
) {}
|
|
|
|
parseAll(): IoFlowInstruction[] {
|
|
const instructions: IoFlowInstruction[] = [];
|
|
while (!this.isAtEnd()) {
|
|
if (this.checkKeyword("wait")) {
|
|
instructions.push(this.finishWait(this.advance()));
|
|
continue;
|
|
}
|
|
if (this.checkKeyword("pulse")) {
|
|
instructions.push(this.finishPulse(this.advance()));
|
|
continue;
|
|
}
|
|
if (this.checkIoStart()) {
|
|
instructions.push(this.finishIoWrite(this.peek()));
|
|
continue;
|
|
}
|
|
this.advance();
|
|
}
|
|
return instructions;
|
|
}
|
|
|
|
private finishIoWrite(start: GrlToken): IoWriteInstruction {
|
|
const target = this.parseIoReference();
|
|
this.consumeOperator("=", "Expected = in IO assignment");
|
|
const value = this.parseValue();
|
|
return {
|
|
kind: "IO_WRITE",
|
|
target,
|
|
value,
|
|
sourceMap: tokenSourceMap(start)
|
|
};
|
|
}
|
|
|
|
private finishWait(start: GrlToken): WaitInstruction {
|
|
const conditionTokens = this.collectUntilKeyword(["timeout", "on_timeout"]);
|
|
if (conditionTokens.length === 0) {
|
|
throw ioError("GRL_WAIT_CONDITION_MISSING", "wait requires a condition");
|
|
}
|
|
|
|
let timeout: number | undefined;
|
|
let onTimeout: WaitInstruction["onTimeout"];
|
|
if (this.matchKeyword("timeout")) {
|
|
timeout = this.parseDuration();
|
|
}
|
|
if (this.matchKeyword("on_timeout")) {
|
|
const kind = this.consumeIdentifier("Expected on_timeout action").raw;
|
|
if (kind === "alarm") {
|
|
const message = this.consumeString("Expected alarm message");
|
|
onTimeout = { kind: "alarm", value: message.value };
|
|
} else if (kind === "call") {
|
|
onTimeout = { kind: "call", value: this.collectRest().map((token) => token.raw).join(" ") };
|
|
} else {
|
|
throw ioError("GRL_WAIT_TIMEOUT_ACTION_INVALID", `Unsupported on_timeout action ${kind}`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
kind: "WAIT",
|
|
condition: conditionTokens.map((token) => token.raw).join(" "),
|
|
...(timeout !== undefined ? { timeout } : {}),
|
|
...(onTimeout ? { onTimeout } : {}),
|
|
sourceMap: tokenSourceMap(start)
|
|
};
|
|
}
|
|
|
|
private finishPulse(start: GrlToken): PulseInstruction {
|
|
const target = this.parseIoReference();
|
|
this.consumeKeyword("duration", "Expected duration in pulse");
|
|
const duration = this.parseDuration();
|
|
return {
|
|
kind: "PULSE",
|
|
target,
|
|
duration,
|
|
trace: [
|
|
{ time: 0, action: "set", target, value: true },
|
|
{ time: duration, action: "reset", target, value: false }
|
|
],
|
|
sourceMap: tokenSourceMap(start)
|
|
};
|
|
}
|
|
|
|
private parseIoReference(): IoReference {
|
|
const io = this.consumeKeyword("io", "Expected io reference");
|
|
this.consumePunctuation(".", "Expected . after io");
|
|
const domain = this.consumeIdentifier("Expected IO domain").raw as IoReference["domain"];
|
|
if (!["di", "do", "ai", "ao", "gi", "go", "ri", "ro", "alias"].includes(domain)) {
|
|
throw ioError("GRL_IO_DOMAIN_INVALID", `Unsupported IO domain ${domain}`);
|
|
}
|
|
|
|
if (domain === "alias") {
|
|
this.consumePunctuation(".", "Expected . after io.alias");
|
|
const alias = this.consumeIdentifier("Expected IO alias").raw;
|
|
const mapped = this.ioMap.aliases?.[alias];
|
|
return mapped ?? { domain, alias, raw: `io.alias.${alias}` };
|
|
}
|
|
|
|
this.consumePunctuation("[", "Expected [ after IO domain");
|
|
const indexToken = this.consumeNumber("Expected IO index");
|
|
this.consumePunctuation("]", "Expected ] after IO index");
|
|
const index = indexToken.value;
|
|
this.validateIoIndex(domain, index, io);
|
|
return {
|
|
domain,
|
|
index,
|
|
raw: `io.${domain}[${index}]`
|
|
};
|
|
}
|
|
|
|
private validateIoIndex(domain: IoReference["domain"], index: number, token: GrlToken): void {
|
|
if (!Number.isInteger(index) || index < 0) {
|
|
throw ioError("GRL_IO_INDEX_INVALID", `Invalid IO index ${index}`);
|
|
}
|
|
const range = this.ioMap.allowedRanges?.[domain];
|
|
if (range && (index < range.min || index > range.max)) {
|
|
throw new KdlStructuredError(
|
|
"GRL_IO_ADDRESS_NOT_FOUND",
|
|
`IO address io.${domain}[${index}] is outside [${range.min}, ${range.max}]`,
|
|
[
|
|
{
|
|
severity: "error",
|
|
code: "GRL_IO_ADDRESS_NOT_FOUND",
|
|
message: `IO address io.${domain}[${index}] is outside [${range.min}, ${range.max}]`,
|
|
sourceMap: tokenSourceMap(token)
|
|
}
|
|
]
|
|
);
|
|
}
|
|
}
|
|
|
|
private parseValue(): boolean | number | string {
|
|
const token = this.advance();
|
|
if (token.kind === "keyword" && (token.raw === "true" || token.raw === "false")) {
|
|
return token.raw === "true";
|
|
}
|
|
if (token.kind === "number") {
|
|
return evaluateNumberExpression(numberLiteralFromToken(token));
|
|
}
|
|
if (token.kind === "string") {
|
|
return token.value;
|
|
}
|
|
if (token.kind === "identifier" || token.kind === "keyword") {
|
|
return token.raw;
|
|
}
|
|
throw ioError("GRL_IO_VALUE_INVALID", "Unsupported IO assignment value");
|
|
}
|
|
|
|
private parseDuration(): number {
|
|
const tokens = this.collectDurationExpressionTokens();
|
|
return evaluateNumberExpression(parseGrlExpression(tokens), { expectedKind: "time", defaultUnit: "s" });
|
|
}
|
|
|
|
private collectDurationExpressionTokens(): GrlToken[] {
|
|
const tokens: GrlToken[] = [];
|
|
let parenDepth = 0;
|
|
let bracketDepth = 0;
|
|
while (!this.isAtEnd()) {
|
|
const token = this.peek();
|
|
if (
|
|
tokens.length > 0 &&
|
|
parenDepth === 0 &&
|
|
bracketDepth === 0 &&
|
|
(this.isKeywordLike(token, "on_timeout") || this.isCurrentStatementStartAfter(tokens))
|
|
) {
|
|
break;
|
|
}
|
|
const consumed = this.advance();
|
|
tokens.push(consumed);
|
|
if (consumed.kind === "punctuation" && consumed.raw === "(") {
|
|
parenDepth += 1;
|
|
} else if (consumed.kind === "punctuation" && consumed.raw === ")") {
|
|
parenDepth = Math.max(0, parenDepth - 1);
|
|
} else if (consumed.kind === "punctuation" && consumed.raw === "[") {
|
|
bracketDepth += 1;
|
|
} else if (consumed.kind === "punctuation" && consumed.raw === "]") {
|
|
bracketDepth = Math.max(0, bracketDepth - 1);
|
|
}
|
|
}
|
|
if (tokens.length === 0) {
|
|
throw ioError("GRL_TOKEN_EXPECTED", "Expected duration");
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
private collectUntilKeyword(keywords: string[]): GrlToken[] {
|
|
const tokens: GrlToken[] = [];
|
|
let parenDepth = 0;
|
|
while (!this.isAtEnd()) {
|
|
const token = this.peek();
|
|
if (parenDepth === 0) {
|
|
if ((token.kind === "keyword" || token.kind === "identifier") && keywords.includes(token.raw)) {
|
|
break;
|
|
}
|
|
if (tokens.length > 0 && this.isCurrentStatementStartAfter(tokens)) {
|
|
break;
|
|
}
|
|
}
|
|
const consumed = this.advance();
|
|
tokens.push(consumed);
|
|
if (consumed.kind === "punctuation" && consumed.raw === "(") {
|
|
parenDepth += 1;
|
|
} else if (consumed.kind === "punctuation" && consumed.raw === ")") {
|
|
parenDepth = Math.max(0, parenDepth - 1);
|
|
}
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
private collectRest(): GrlToken[] {
|
|
const tokens: GrlToken[] = [];
|
|
while (!this.isAtEnd()) {
|
|
if (tokens.length > 0 && this.isCurrentStatementStartAfter(tokens)) {
|
|
break;
|
|
}
|
|
tokens.push(this.advance());
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
private checkIoStart(): boolean {
|
|
return this.isIoStartAtCurrent();
|
|
}
|
|
|
|
private isCurrentStatementStartAfter(tokens: GrlToken[]): boolean {
|
|
const token = this.peek();
|
|
const previous = tokens.at(-1);
|
|
if (!previous || token.range.start.line <= previous.range.end.line) {
|
|
return false;
|
|
}
|
|
if (this.isStatementStart(token)) {
|
|
return true;
|
|
}
|
|
if (!this.isIoStartAtCurrent()) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
private isIoStartAtCurrent(): boolean {
|
|
return this.peek().raw === "io" && this.maybePeek(1)?.raw === ".";
|
|
}
|
|
|
|
private isStatementStart(token: GrlToken): boolean {
|
|
return (
|
|
(token.kind === "keyword" || token.kind === "identifier") &&
|
|
[
|
|
"io",
|
|
"wait",
|
|
"pulse",
|
|
"movej",
|
|
"movel",
|
|
"movec",
|
|
"set_tool",
|
|
"set_frame",
|
|
"set_speed",
|
|
"set_zone",
|
|
"run_path",
|
|
"run_operation",
|
|
"if",
|
|
"elseif",
|
|
"else",
|
|
"while",
|
|
"for",
|
|
"switch",
|
|
"case",
|
|
"default",
|
|
"break",
|
|
"continue",
|
|
"label",
|
|
"jump",
|
|
"call",
|
|
"return",
|
|
"alarm",
|
|
"raise",
|
|
"try",
|
|
"catch",
|
|
"finally",
|
|
"end"
|
|
].includes(token.raw)
|
|
);
|
|
}
|
|
|
|
private isKeywordLike(token: GrlToken, keyword: string): boolean {
|
|
return (token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword;
|
|
}
|
|
|
|
private matchKeyword(keyword: string): boolean {
|
|
const token = this.peek();
|
|
if ((token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword) {
|
|
this.advance();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
private checkKeyword(keyword: string): boolean {
|
|
const token = this.peek();
|
|
return (token.kind === "keyword" || token.kind === "identifier") && token.raw === keyword;
|
|
}
|
|
|
|
private consumeKeyword(keyword: string, message: string): GrlToken {
|
|
if (this.checkKeyword(keyword)) {
|
|
return this.advance();
|
|
}
|
|
throw ioError("GRL_KEYWORD_EXPECTED", message);
|
|
}
|
|
|
|
private consumeIdentifier(message: string): GrlToken {
|
|
const token = this.peek();
|
|
if (token.kind === "identifier" || token.kind === "keyword") {
|
|
return this.advance();
|
|
}
|
|
throw ioError("GRL_IDENTIFIER_EXPECTED", message);
|
|
}
|
|
|
|
private consumePunctuation(value: string, message: string): GrlToken {
|
|
const token = this.peek();
|
|
if (token.kind === "punctuation" && token.raw === value) {
|
|
return this.advance();
|
|
}
|
|
throw ioError("GRL_PUNCTUATION_EXPECTED", message);
|
|
}
|
|
|
|
private consumeOperator(value: string, message: string): GrlToken {
|
|
const token = this.peek();
|
|
if (token.kind === "operator" && token.raw === value) {
|
|
return this.advance();
|
|
}
|
|
throw ioError("GRL_OPERATOR_EXPECTED", message);
|
|
}
|
|
|
|
private consume(kind: GrlToken["kind"], message: string): GrlToken {
|
|
if (this.peek().kind === kind) {
|
|
return this.advance();
|
|
}
|
|
throw ioError("GRL_TOKEN_EXPECTED", message);
|
|
}
|
|
|
|
private consumeNumber(message: string): Extract<GrlToken, { kind: "number" }> {
|
|
const token = this.peek();
|
|
if (token.kind === "number") {
|
|
return this.advance() as Extract<GrlToken, { kind: "number" }>;
|
|
}
|
|
throw ioError("GRL_TOKEN_EXPECTED", message);
|
|
}
|
|
|
|
private consumeString(message: string): Extract<GrlToken, { kind: "string" }> {
|
|
const token = this.peek();
|
|
if (token.kind === "string") {
|
|
return this.advance() as Extract<GrlToken, { kind: "string" }>;
|
|
}
|
|
throw ioError("GRL_TOKEN_EXPECTED", message);
|
|
}
|
|
|
|
private advance(): GrlToken {
|
|
this.current += 1;
|
|
return this.previous();
|
|
}
|
|
|
|
private previous(): GrlToken {
|
|
return this.tokens[this.current - 1]!;
|
|
}
|
|
|
|
private peek(): GrlToken {
|
|
return this.tokens[this.current]!;
|
|
}
|
|
|
|
private maybePeek(distance = 0): GrlToken | undefined {
|
|
return this.tokens[this.current + distance];
|
|
}
|
|
|
|
private isAtEnd(): boolean {
|
|
return this.current >= this.tokens.length;
|
|
}
|
|
}
|
|
|
|
function compileStatementString(
|
|
statement: string,
|
|
_sourceMap: MotionSourceMap | undefined,
|
|
ioMap: IoMap
|
|
): IoFlowInstruction[] {
|
|
const tokens = lexGrl(statement, { preserveComments: false }).filter(
|
|
(token) => token.kind !== "eof" && token.kind !== "comment"
|
|
);
|
|
return parseIoFlowStatements(tokens, ioMap);
|
|
}
|
|
|
|
function tokenSourceMap(token: GrlToken): MotionSourceMap {
|
|
return {
|
|
line: token.range.start.line,
|
|
column: token.range.start.column
|
|
};
|
|
}
|
|
|
|
function numberLiteralFromToken(token: Extract<GrlToken, { kind: "number" }>): GrlNumberLiteral {
|
|
return {
|
|
kind: "NumberLiteral",
|
|
value: token.value,
|
|
raw: token.raw,
|
|
...(token.unit
|
|
? {
|
|
unit: {
|
|
raw: token.unit.raw,
|
|
kind: token.unit.kind,
|
|
siUnit: token.unit.siUnit,
|
|
normalizedValue: token.unit.normalizedValue
|
|
}
|
|
}
|
|
: {}),
|
|
range: token.range
|
|
};
|
|
}
|
|
|
|
function ioError(code: string, message: string): KdlStructuredError {
|
|
return new KdlStructuredError(code, message);
|
|
}
|