diff --git a/packages/ros1/package.json b/packages/ros1/package.json index 463c0367..c1d3fb3e 100644 --- a/packages/ros1/package.json +++ b/packages/ros1/package.json @@ -57,7 +57,7 @@ "typescript": "5.7.2" }, "dependencies": { - "@foxglove/message-definition": "^0.2.0", + "@foxglove/message-definition": "^0.4.0", "@foxglove/rosmsg": "workspace:^", "@foxglove/rosmsg-serialization": "workspace:^", "@foxglove/xmlrpc": "workspace:^", diff --git a/packages/rosmsg-msgs-common/package.json b/packages/rosmsg-msgs-common/package.json index bb8701a0..58fa6b6b 100644 --- a/packages/rosmsg-msgs-common/package.json +++ b/packages/rosmsg-msgs-common/package.json @@ -50,7 +50,7 @@ "typescript": "5.7.2" }, "dependencies": { - "@foxglove/message-definition": "^0.3.1", + "@foxglove/message-definition": "^0.4.0", "@foxglove/rosmsg": "workspace:^" } } diff --git a/packages/rosmsg-serialization/package.json b/packages/rosmsg-serialization/package.json index e39320d6..e8bdec22 100644 --- a/packages/rosmsg-serialization/package.json +++ b/packages/rosmsg-serialization/package.json @@ -62,6 +62,6 @@ "typescript": "5.7.2" }, "dependencies": { - "@foxglove/message-definition": "^0.3.1" + "@foxglove/message-definition": "^0.4.0" } } diff --git a/packages/rosmsg/package.json b/packages/rosmsg/package.json index c397f93b..fc89c06c 100644 --- a/packages/rosmsg/package.json +++ b/packages/rosmsg/package.json @@ -39,7 +39,7 @@ "node": ">= 14" }, "dependencies": { - "@foxglove/message-definition": "^0.3.1", + "@foxglove/message-definition": "^0.4.0", "md5-typescript": "^1.0.5" }, "devDependencies": { diff --git a/packages/rosmsg/src/buildRos2Type.ts b/packages/rosmsg/src/buildRos2Type.ts index b672ade3..c8a837ec 100644 --- a/packages/rosmsg/src/buildRos2Type.ts +++ b/packages/rosmsg/src/buildRos2Type.ts @@ -1,4 +1,6 @@ -import { MessageDefinition, MessageDefinitionField } from "@foxglove/message-definition"; +import { MessageDefinitionField } from "@foxglove/message-definition"; + +import { NamedMessageDefinition } from "./types"; /** * Parser for ROS 2 type definition lines. @@ -211,14 +213,24 @@ function normalizeType(type: string): string { } return type; } -export function buildRos2Type(lines: { line: string }[]): MessageDefinition { + +/** + * @param topLevelTypeName Required if this is a top-level type that does not contain a "MSG:" line. + */ +export function buildRos2Type( + lines: { line: string }[], + topLevelTypeName?: string, +): NamedMessageDefinition { const definitions: MessageDefinitionField[] = []; - let complexTypeName: string | undefined; + let complexTypeName = topLevelTypeName; for (const { line } of lines) { let match; if (line.startsWith("#")) { continue; } else if ((match = /^MSG: ([^ ]+)\s*(?:#.+)?$/.exec(line))) { + if (complexTypeName != undefined) { + throw new Error(`Unexpected MSG name in top-level type: ${complexTypeName}, ${match[1]!}`); + } complexTypeName = match[1]; continue; } else if ((match = DEFINITION_LINE_REGEX.exec(line))) { @@ -274,5 +286,8 @@ export function buildRos2Type(lines: { line: string }[]): MessageDefinition { throw new Error(`Could not parse line: '${line}'`); } } + if (complexTypeName == undefined) { + throw new Error("Missing name for top-level type definition"); + } return { name: complexTypeName, definitions }; } diff --git a/packages/rosmsg/src/index.ts b/packages/rosmsg/src/index.ts index a30dd1a5..4903446c 100644 --- a/packages/rosmsg/src/index.ts +++ b/packages/rosmsg/src/index.ts @@ -3,3 +3,4 @@ export * from "./md5"; export * from "./parse"; export * from "./stringify"; +export * from "./types"; diff --git a/packages/rosmsg/src/md5.test.ts b/packages/rosmsg/src/md5.test.ts index 97673e17..2b0d46d5 100644 --- a/packages/rosmsg/src/md5.test.ts +++ b/packages/rosmsg/src/md5.test.ts @@ -111,6 +111,6 @@ string frame_id`, describe("md5", () => { it.each(md5Tests)("should checksum %s", (_name, msgDef, expected) => { - expect(md5(parse(msgDef))).toBe(expected); + expect(md5(parse(msgDef, { topLevelTypeName: "Ignored" }))).toBe(expected); }); }); diff --git a/packages/rosmsg/src/parse.ros1.test.ts b/packages/rosmsg/src/parse.ros1.test.ts index 21d64df0..3d76dacb 100644 --- a/packages/rosmsg/src/parse.ros1.test.ts +++ b/packages/rosmsg/src/parse.ros1.test.ts @@ -13,9 +13,10 @@ import { fixupTypes, parse } from "./parse"; describe("parseMessageDefinition", () => { it("parses a single field from a single message", () => { - const types = parse("string name"); + const types = parse("string name", { topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, @@ -25,30 +26,29 @@ describe("parseMessageDefinition", () => { type: "string", }, ], - name: undefined, }, ]); }); it("rejects valid tokens that don't fully match a parser rule", () => { - expect(() => parse("abc")).toThrow("Could not parse line: 'abc'"); + expect(() => parse("abc", { topLevelTypeName: "x" })).toThrow("Could not parse line: 'abc'"); }); it.each(["_a", "3a"])("rejects invalid field name %s", (name) => { - expect(() => parse(`string ${name}`)).toThrow(); + expect(() => parse(`string ${name}`, { topLevelTypeName: "x" })).toThrow(); }); it.each(["3a"])("rejects invalid constant name %s", (name) => { - expect(() => parse(`string ${name} = 'x'`)).toThrow(); + expect(() => parse(`string ${name} = 'x'`, { topLevelTypeName: "x" })).toThrow(); }); it.each(["a", "a_", "foo_bar", "foo__bar", "foo1_2bar"])( "accepts valid field name %s", (name) => { - expect(parse(`string ${name}`)).toEqual([ + expect(parse(`string ${name}`, { topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, isArray: false, isComplex: false, name, type: "string" }, ], - name: undefined, }, ]); }, @@ -56,10 +56,10 @@ describe("parseMessageDefinition", () => { it.each(["a", "_a", "a_", "foo_bar", "foo__Bar", "FOO1_2BAR"])( "accepts valid constant name %s", (name) => { - expect(parse(`string ${name} = x`)).toEqual([ + expect(parse(`string ${name} = x`, { topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [{ name, type: "string", isConstant: true, value: "x", valueText: "x" }], - name: undefined, }, ]); }, @@ -72,9 +72,10 @@ describe("parseMessageDefinition", () => { MSG: geometry_msgs/Point float64 x `; - const types = parse(messageDefinition); + const types = parse(messageDefinition, { topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, @@ -84,9 +85,9 @@ describe("parseMessageDefinition", () => { type: "geometry_msgs/Point", }, ], - name: undefined, }, { + name: "geometry_msgs/Point", definitions: [ { arrayLength: undefined, @@ -96,15 +97,15 @@ describe("parseMessageDefinition", () => { type: "float64", }, ], - name: "geometry_msgs/Point", }, ]); }); it("normalizes aliases", () => { - const types = parse("char x\nbyte y"); + const types = parse("char x\nbyte y", { topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, @@ -121,7 +122,6 @@ describe("parseMessageDefinition", () => { type: "int8", }, ], - name: undefined, }, ]); }); @@ -135,9 +135,10 @@ describe("parseMessageDefinition", () => { ### foo bar baz? string lastName `; - const types = parse(messageDefinition); + const types = parse(messageDefinition, { topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, @@ -154,15 +155,15 @@ describe("parseMessageDefinition", () => { type: "string", }, ], - name: undefined, }, ]); }); it.each(["string", "int32", "int64"])("parses variable length %s array", (type) => { - const types = parse(`${type}[] names`); + const types = parse(`${type}[] names`, { topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, @@ -172,15 +173,15 @@ describe("parseMessageDefinition", () => { type, }, ], - name: undefined, }, ]); }); it("parses fixed length string array", () => { - const types = parse("string[3] names"); + const types = parse("string[3] names", { topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: 3, @@ -190,7 +191,6 @@ describe("parseMessageDefinition", () => { type: "string", }, ], - name: undefined, }, ]); }); @@ -204,9 +204,10 @@ describe("parseMessageDefinition", () => { string name uint16 id `; - const types = parse(messageDefinition); + const types = parse(messageDefinition, { topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, @@ -223,9 +224,9 @@ describe("parseMessageDefinition", () => { type: "custom_type/Account", }, ], - name: undefined, }, { + name: "custom_type/Account", definitions: [ { arrayLength: undefined, @@ -242,7 +243,6 @@ describe("parseMessageDefinition", () => { type: "uint16", }, ], - name: "custom_type/Account", }, ]); }); @@ -261,9 +261,10 @@ describe("parseMessageDefinition", () => { uint64 SMOOTH_MOVE_START = 0000000000000001 # e.g. kobuki_msgs/VersionInfo int64 LARGE_VALUE = -9223372036854775807 `; - const types = parse(messageDefinition); + const types = parse(messageDefinition, { topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { name: "foo", @@ -343,7 +344,6 @@ describe("parseMessageDefinition", () => { valueText: "-9223372036854775807", }, ], - name: undefined, }, ]); }); @@ -353,9 +353,10 @@ describe("parseMessageDefinition", () => { bool Alive=True bool Dead=False `; - const types = parse(messageDefinition); + const types = parse(messageDefinition, { topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { name: "Alive", @@ -372,14 +373,14 @@ describe("parseMessageDefinition", () => { valueText: "False", }, ], - name: undefined, }, ]); }); it("handles type names for fields", () => { - expect(parse(`time time`)).toEqual([ + expect(parse(`time time`, { topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { name: "time", @@ -388,12 +389,12 @@ describe("parseMessageDefinition", () => { isComplex: false, }, ], - name: undefined, }, ]); - expect(parse(`time time_ref`)).toEqual([ + expect(parse(`time time_ref`, { topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { name: "time_ref", @@ -402,19 +403,22 @@ describe("parseMessageDefinition", () => { isComplex: false, }, ], - name: undefined, }, ]); expect( - parse(` + parse( + ` true true ============ MSG: custom/true bool false - `), + `, + { topLevelTypeName: "Dummy" }, + ), ).toEqual([ { + name: "Dummy", definitions: [ { name: "true", @@ -423,7 +427,6 @@ describe("parseMessageDefinition", () => { isComplex: true, }, ], - name: undefined, }, { definitions: [ @@ -441,20 +444,23 @@ describe("parseMessageDefinition", () => { it("allows numbers in package names", () => { expect( - parse(` + parse( + ` abc1/Foo2 value0 ========== MSG: abc1/Foo2 int32 data - `), + `, + { topLevelTypeName: "Dummy" }, + ), ).toEqual([ { + name: "Dummy", definitions: [{ isArray: false, isComplex: true, name: "value0", type: "abc1/Foo2" }], - name: undefined, }, { - definitions: [{ isArray: false, isComplex: false, name: "data", type: "int32" }], name: "abc1/Foo2", + definitions: [{ isArray: false, isComplex: false, name: "data", type: "int32" }], }, ]); }); @@ -474,25 +480,66 @@ describe("fixupTypes", () => { MSG: geometry_msgs/Point float64 x `; - const types = parse(messageDefinition, { skipTypeFixup: true }); + const types = parse(messageDefinition, { skipTypeFixup: true, topLevelTypeName: "Points" }); - expect(types).toHaveLength(2); - expect(types[0]!.definitions).toHaveLength(1); - expect(types[0]!.definitions[0]!.type).toEqual("Point"); - expect(types[1]!.definitions).toHaveLength(1); - expect(types[1]!.definitions[0]!.type).toEqual("float64"); + expect(types).toEqual([ + { + name: "Points", + definitions: [ + { + isArray: true, + isComplex: true, + name: "points", + type: "Point", + }, + ], + }, + { + name: "geometry_msgs/Point", + definitions: [ + { + isArray: false, + isComplex: false, + name: "x", + type: "float64", + }, + ], + }, + ]); fixupTypes(types); - expect(types).toHaveLength(2); - expect(types[0]!.definitions).toHaveLength(1); - expect(types[0]!.definitions[0]!.type).toEqual("geometry_msgs/Point"); - expect(types[1]!.definitions).toHaveLength(1); - expect(types[1]!.definitions[0]!.type).toEqual("float64"); + expect(types).toEqual([ + { + name: "Points", + definitions: [ + { + isArray: true, + isComplex: true, + name: "points", + type: "geometry_msgs/Point", + }, + ], + }, + { + name: "geometry_msgs/Point", + definitions: [ + { + isArray: false, + isComplex: false, + name: "x", + type: "float64", + }, + ], + }, + ]); }); it("does not mixup types with same name but different namespace", () => { const messageDefinition = ` + int32 dummy + + === MSG: visualization_msgs/Marker int32 a @@ -508,8 +555,19 @@ describe("fixupTypes", () => { MSG: aruco_msgs/MarkerArray Marker[] b `; - const types = parse(messageDefinition); + const types = parse(messageDefinition, { topLevelTypeName: "Dummy" }); expect(types).toEqual([ + { + name: "Dummy", + definitions: [ + { + type: "int32", + isArray: false, + name: "dummy", + isComplex: false, + }, + ], + }, { name: "visualization_msgs/Marker", definitions: [ @@ -569,9 +627,10 @@ describe("fixupTypes", () => { uint32 seq time stamp string frame_id`; - const types = parse(messageDefinition); + const types = parse(messageDefinition, { topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { type: "custom_msg/StampedBool", @@ -642,9 +701,10 @@ describe("fixupTypes", () => { uint64 u `; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { type: "foo_msgs/TypeA", @@ -690,4 +750,225 @@ describe("fixupTypes", () => { }, ]); }); + + describe("enum inference", () => { + it("handles various constant types", () => { + expect( + parse( + ` + uint32 OFF=0 + uint32 ON=1 + uint32 state + uint8 RED=0 + uint8 YELLOW=1 + uint8 GREEN=2 + uint8 color + uint64 ONE=1 + uint64 TWO=2 + uint64 large_number + + === + MSG: my_msgs/NestedMsg + string FOO=foo + string BAR=bar + string str + bool YEP=True + bool NOPE=False + bool maybe + `, + { topLevelTypeName: "Dummy" }, + ), + ).toEqual([ + { + name: "Dummy", + definitions: [ + { type: "uint32", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint32", name: "ON", isConstant: true, value: 1, valueText: "1" }, + { + type: "uint32", + name: "state", + isArray: false, + isComplex: false, + enumType: "enum for Dummy.state", + }, + { type: "uint8", name: "RED", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "YELLOW", isConstant: true, value: 1, valueText: "1" }, + { type: "uint8", name: "GREEN", isConstant: true, value: 2, valueText: "2" }, + { + type: "uint8", + name: "color", + isArray: false, + isComplex: false, + enumType: "enum for Dummy.color", + }, + { type: "uint64", name: "ONE", isConstant: true, value: 1n, valueText: "1" }, + { type: "uint64", name: "TWO", isConstant: true, value: 2n, valueText: "2" }, + { + type: "uint64", + name: "large_number", + isArray: false, + isComplex: false, + enumType: "enum for Dummy.large_number", + }, + ], + }, + { + name: "my_msgs/NestedMsg", + definitions: [ + { type: "string", name: "FOO", isConstant: true, value: "foo", valueText: "foo" }, + { type: "string", name: "BAR", isConstant: true, value: "bar", valueText: "bar" }, + { + type: "string", + name: "str", + isArray: false, + isComplex: false, + enumType: "enum for my_msgs/NestedMsg.str", + }, + { type: "bool", name: "YEP", isConstant: true, value: true, valueText: "True" }, + { type: "bool", name: "NOPE", isConstant: true, value: false, valueText: "False" }, + { + type: "bool", + name: "maybe", + isArray: false, + isComplex: false, + enumType: "enum for my_msgs/NestedMsg.maybe", + }, + ], + }, + { + name: "enum for Dummy.state", + definitions: [ + { type: "uint32", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint32", name: "ON", isConstant: true, value: 1, valueText: "1" }, + ], + }, + { + name: "enum for Dummy.color", + definitions: [ + { type: "uint8", name: "RED", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "YELLOW", isConstant: true, value: 1, valueText: "1" }, + { type: "uint8", name: "GREEN", isConstant: true, value: 2, valueText: "2" }, + ], + }, + { + name: "enum for Dummy.large_number", + definitions: [ + { type: "uint64", name: "ONE", isConstant: true, value: 1n, valueText: "1" }, + { type: "uint64", name: "TWO", isConstant: true, value: 2n, valueText: "2" }, + ], + }, + { + name: "enum for my_msgs/NestedMsg.str", + definitions: [ + { type: "string", name: "FOO", isConstant: true, value: "foo", valueText: "foo" }, + { type: "string", name: "BAR", isConstant: true, value: "bar", valueText: "bar" }, + ], + }, + { + name: "enum for my_msgs/NestedMsg.maybe", + definitions: [ + { type: "bool", name: "YEP", isConstant: true, value: true, valueText: "True" }, + { type: "bool", name: "NOPE", isConstant: true, value: false, valueText: "False" }, + ], + }, + ]); + }); + + it("handles multiple blocks of constants of the same type", () => { + expect( + parse( + ` + uint8 OFF=0 + uint8 ON=1 + uint8 state1 + uint8 FOO=0 + uint8 BAR=1 + uint8 state2 + `, + { topLevelTypeName: "Dummy" }, + ), + ).toEqual([ + { + name: "Dummy", + definitions: [ + { type: "uint8", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "ON", isConstant: true, value: 1, valueText: "1" }, + { + type: "uint8", + name: "state1", + isArray: false, + isComplex: false, + enumType: "enum for Dummy.state1", + }, + { type: "uint8", name: "FOO", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "BAR", isConstant: true, value: 1, valueText: "1" }, + { + type: "uint8", + name: "state2", + isArray: false, + isComplex: false, + enumType: "enum for Dummy.state2", + }, + ], + }, + { + name: "enum for Dummy.state1", + definitions: [ + { type: "uint8", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "ON", isConstant: true, value: 1, valueText: "1" }, + ], + }, + { + name: "enum for Dummy.state2", + definitions: [ + { type: "uint8", name: "FOO", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "BAR", isConstant: true, value: 1, valueText: "1" }, + ], + }, + ]); + }); + + it("only assigns constants to matching types", () => { + expect( + parse( + ` + uint8 OFF=0 + uint8 ON=1 + uint32 state32 + uint8 state8 + `, + { topLevelTypeName: "Dummy" }, + ), + ).toEqual([ + { + name: "Dummy", + definitions: [ + { type: "uint8", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "ON", isConstant: true, value: 1, valueText: "1" }, + { type: "uint32", name: "state32", isArray: false, isComplex: false }, + { type: "uint8", name: "state8", isArray: false, isComplex: false }, + ], + }, + // no enums inferred as the first type after constants doesn't match constant type + ]); + }); + + it("skips enums if requested", () => { + expect( + parse("uint8 OFF=0\nuint8 ON=1\nuint8 state", { + topLevelTypeName: "Dummy", + skipEnums: true, + }), + ).toEqual([ + { + name: "Dummy", + definitions: [ + { type: "uint8", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "ON", isConstant: true, value: 1, valueText: "1" }, + { type: "uint8", name: "state", isArray: false, isComplex: false }, + ], + }, + ]); + }); + }); }); diff --git a/packages/rosmsg/src/parse.ros2.test.ts b/packages/rosmsg/src/parse.ros2.test.ts index c1865775..cc2a3904 100644 --- a/packages/rosmsg/src/parse.ros2.test.ts +++ b/packages/rosmsg/src/parse.ros2.test.ts @@ -7,13 +7,21 @@ // found at http://www.apache.org/licenses/LICENSE-2.0 // You may not use this file except in compliance with the License. -import { parse } from "./parse"; +import { ParseOptions, parse as parseRos1OrRos2 } from "./parse"; +import { NamedMessageDefinition } from "./types"; + +// Make it a type error to omit {ros2: true} in this ros2 test file +const parse: ( + messageDefinition: string, + options: ParseOptions & { ros2: true }, +) => NamedMessageDefinition[] = parseRos1OrRos2; describe("parseMessageDefinition", () => { it("parses a single field from a single message", () => { - const types = parse("string name", { ros2: true }); + const types = parse("string name", { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, @@ -23,7 +31,6 @@ describe("parseMessageDefinition", () => { type: "string", }, ], - name: undefined, }, ]); }); @@ -36,8 +43,9 @@ describe("parseMessageDefinition", () => { [`#comment`, undefined], [` #comment`, undefined], ])("parses string array default value %s", (literal, value) => { - expect(parse(`string[] name ${literal}`, { ros2: true })).toEqual([ + expect(parse(`string[] name ${literal}`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, @@ -48,7 +56,6 @@ describe("parseMessageDefinition", () => { defaultValue: value, }, ], - name: undefined, }, ]); }); @@ -56,12 +63,15 @@ describe("parseMessageDefinition", () => { it.each([`[,]`, `[,a]`, `[a,']`, `[`, `]`, `[a,b]x`])( "rejects invalid string array literal %s, but accepts it as a string literal", (literal) => { - expect(() => parse(`string[] name ${literal}`, { ros2: true })).toThrow( + expect(() => + parse(`string[] name ${literal}`, { ros2: true, topLevelTypeName: "Dummy" }), + ).toThrow( /Expected comma or end of array|Expected array element before comma|Array must start with \[ and end with \]/, ); - expect(parse(`string name ${literal}`, { ros2: true })).toEqual([ + expect(parse(`string name ${literal}`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, @@ -72,12 +82,14 @@ describe("parseMessageDefinition", () => { defaultValue: literal, }, ], - name: undefined, }, ]); - expect(parse(`string name ${literal}#comment`, { ros2: true })).toEqual([ + expect( + parse(`string name ${literal}#comment`, { ros2: true, topLevelTypeName: "Dummy" }), + ).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, @@ -88,12 +100,14 @@ describe("parseMessageDefinition", () => { defaultValue: literal, }, ], - name: undefined, }, ]); - expect(parse(`string name ${literal} #comment`, { ros2: true })).toEqual([ + expect( + parse(`string name ${literal} #comment`, { ros2: true, topLevelTypeName: "Dummy" }), + ).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, @@ -104,36 +118,39 @@ describe("parseMessageDefinition", () => { defaultValue: literal, }, ], - name: undefined, }, ]); }, ); it("rejects valid tokens that don't fully match a parser rule", () => { - expect(() => parse("abc", { ros2: true })).toThrow("Could not parse line: 'abc'"); + expect(() => parse("abc", { ros2: true, topLevelTypeName: "Dummy" })).toThrow( + "Could not parse line: 'abc'", + ); }); it.each(["A", "aB", "a_", "_a", "a__b", "3a"])("rejects invalid field name %s", (name) => { - expect(() => parse(`string ${name}`, { ros2: true })).toThrow(); + expect(() => parse(`string ${name}`, { ros2: true, topLevelTypeName: "Dummy" })).toThrow(); }); it.each(["a", "aB", "A_", "_A", "A__B", "3A"])("rejects invalid constant name %s", (name) => { - expect(() => parse(`string ${name} = 'x'`, { ros2: true })).toThrow(); + expect(() => + parse(`string ${name} = 'x'`, { ros2: true, topLevelTypeName: "Dummy" }), + ).toThrow(); }); it.each(["a", "foo_bar", "foo1_2bar"])("accepts valid field name %s", (name) => { - expect(parse(`string ${name}`, { ros2: true })).toEqual([ + expect(parse(`string ${name}`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: undefined, isArray: false, isComplex: false, name, type: "string" }, ], - name: undefined, }, ]); }); it.each(["A", "A_B", "FOO1_2BAR"])("accepts valid constant name %s", (name) => { - expect(parse(`string ${name} = 'x'`, { ros2: true })).toEqual([ + expect(parse(`string ${name} = 'x'`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [{ name, type: "string", isConstant: true, value: "x", valueText: "'x'" }], - name: undefined, }, ]); }); @@ -145,9 +162,10 @@ describe("parseMessageDefinition", () => { MSG: geometry_msgs/Point float64 x `; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { isArray: true, @@ -156,7 +174,6 @@ describe("parseMessageDefinition", () => { type: "geometry_msgs/Point", }, ], - name: undefined, }, { definitions: [ @@ -173,9 +190,10 @@ describe("parseMessageDefinition", () => { }); it("normalizes aliases", () => { - const types = parse("char x\nbyte y", { ros2: true }); + const types = parse("char x\nbyte y", { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { isArray: false, @@ -190,7 +208,6 @@ describe("parseMessageDefinition", () => { type: "uint8", }, ], - name: undefined, }, ]); }); @@ -204,9 +221,10 @@ describe("parseMessageDefinition", () => { ### foo bar baz? string last_name `; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { isArray: false, @@ -221,15 +239,15 @@ describe("parseMessageDefinition", () => { type: "string", }, ], - name: undefined, }, ]); }); it("parses variable length string array", () => { - const types = parse("string[] names", { ros2: true }); + const types = parse("string[] names", { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { isArray: true, @@ -238,15 +256,15 @@ describe("parseMessageDefinition", () => { type: "string", }, ], - name: undefined, }, ]); }); it("parses fixed length string array", () => { - const types = parse("string[3] names", { ros2: true }); + const types = parse("string[3] names", { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { arrayLength: 3, @@ -256,7 +274,6 @@ describe("parseMessageDefinition", () => { type: "string", }, ], - name: undefined, }, ]); }); @@ -270,9 +287,10 @@ describe("parseMessageDefinition", () => { string name uint16 id `; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { isArray: false, @@ -287,9 +305,9 @@ describe("parseMessageDefinition", () => { type: "custom_type/Account", }, ], - name: undefined, }, { + name: "custom_type/Account", definitions: [ { isArray: false, @@ -304,7 +322,6 @@ describe("parseMessageDefinition", () => { type: "uint16", }, ], - name: "custom_type/Account", }, ]); }); @@ -326,9 +343,10 @@ describe("parseMessageDefinition", () => { string BLANKSPACECOMMENT= # Blank with comment after space string ESCAPED_QUOTE = \\'a#comment `; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { name: "FOO", @@ -429,7 +447,6 @@ describe("parseMessageDefinition", () => { valueText: "\\'a", }, ], - name: undefined, }, ]); }); @@ -439,9 +456,10 @@ describe("parseMessageDefinition", () => { bool ALIVE=True bool DEAD=False `; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { name: "ALIVE", @@ -458,14 +476,14 @@ describe("parseMessageDefinition", () => { valueText: "False", }, ], - name: undefined, }, ]); }); it("handles type names for fields", () => { - expect(parse(`time time`)).toEqual([ + expect(parse(`time time`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { name: "time", @@ -474,12 +492,12 @@ describe("parseMessageDefinition", () => { isComplex: false, }, ], - name: undefined, }, ]); - expect(parse(`time time_ref`)).toEqual([ + expect(parse(`time time_ref`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { name: "time_ref", @@ -488,19 +506,22 @@ describe("parseMessageDefinition", () => { isComplex: false, }, ], - name: undefined, }, ]); expect( - parse(` + parse( + ` true true ============ MSG: custom/true bool false - `), + `, + { ros2: true, topLevelTypeName: "Dummy" }, + ), ).toEqual([ { + name: "Dummy", definitions: [ { name: "true", @@ -509,9 +530,9 @@ describe("parseMessageDefinition", () => { isComplex: true, }, ], - name: undefined, }, { + name: "custom/true", definitions: [ { name: "false", @@ -520,7 +541,6 @@ describe("parseMessageDefinition", () => { isComplex: false, }, ], - name: "custom/true", }, ]); }); @@ -534,26 +554,28 @@ describe("parseMessageDefinition", () => { MSG: abc1/Foo2 int32 data `, - { ros2: true }, + { ros2: true, topLevelTypeName: "Dummy" }, ), ).toEqual([ { + name: "Dummy", definitions: [{ isArray: false, isComplex: true, name: "value0", type: "abc1/Foo2" }], - name: undefined, }, { - definitions: [{ isArray: false, isComplex: false, name: "data", type: "int32" }], name: "abc1/Foo2", + definitions: [{ isArray: false, isComplex: false, name: "data", type: "int32" }], }, ]); }); it.each(["\\", "hi\\", String.raw`'abc\'`])("rejects invalid string literal %s", (str) => { - expect(() => parse(`string x ${str}`, { ros2: true })).toThrow("Could not parse line"); + expect(() => parse(`string x ${str}`, { ros2: true, topLevelTypeName: "Dummy" })).toThrow( + "Could not parse line", + ); // "Invalid field name" is not an ideal error message but that's ok. It just means the = parsed // as potentially part of a default value, but the name is a constant name (uppercase) rather // than a field name. - expect(() => parse(`string X = ${str}`, { ros2: true })).toThrow( + expect(() => parse(`string X = ${str}`, { ros2: true, topLevelTypeName: "Dummy" })).toThrow( /Invalid field name|Could not parse line/, ); }); @@ -565,18 +587,18 @@ describe("parseMessageDefinition", () => { [`\\foo`, `\foo`], [`[a\\,b]`, `[a\\,b]`], ])("parses unquoted string default/constant value %s", (literal, value) => { - expect(parse(`string x ${literal}`, { ros2: true })).toEqual([ + expect(parse(`string x ${literal}`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { isArray: false, isComplex: false, name: "x", type: "string", defaultValue: value }, ], - name: undefined, }, ]); - expect(parse(`string X = ${literal}`, { ros2: true })).toEqual([ + expect(parse(`string X = ${literal}`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [{ isConstant: true, name: "X", type: "string", valueText: literal, value }], - name: undefined, }, ]); }); @@ -592,29 +614,33 @@ describe("parseMessageDefinition", () => { const expected = `hello'"\x07\b\f\n\r\t\v\\${String.fromCodePoint( 0o12, )}\x019\x10\u1010${String.fromCodePoint(0x2f804)}`; - expect(parse(`string x ${str}`, { ros2: true })).toEqual([ + expect(parse(`string x ${str}`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { name: "x", type: "string", defaultValue: expected, isArray: false, isComplex: false }, ], }, ]); - expect(parse(`string X = ${str} #comment`, { ros2: true })).toEqual([ + expect(parse(`string X = ${str} #comment`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { name: "X", type: "string", isConstant: true, value: expected, valueText: str }, ], }, ]); - expect(parse(`string X =${str}`, { ros2: true })).toEqual([ + expect(parse(`string X =${str}`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { name: "X", type: "string", isConstant: true, value: expected, valueText: str }, ], }, ]); - expect(parse(`string X =${str}#comment`, { ros2: true })).toEqual([ + expect(parse(`string X =${str}#comment`, { ros2: true, topLevelTypeName: "Dummy" })).toEqual([ { + name: "Dummy", definitions: [ { name: "X", type: "string", isConstant: true, value: expected, valueText: str }, ], @@ -623,7 +649,7 @@ describe("parseMessageDefinition", () => { }); it.each(["int32 x abc", "bool x abc"])("rejects literals of incorrect type: %s", (line) => { - expect(() => parse(line, { ros2: true })).toThrow(); + expect(() => parse(line, { ros2: true, topLevelTypeName: "Dummy" })).toThrow(); }); it("parses default values", () => { @@ -642,9 +668,10 @@ describe("parseMessageDefinition", () => { string l '\\'hello\\'' string m \\foo `; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { name: "a", @@ -738,7 +765,6 @@ describe("parseMessageDefinition", () => { isComplex: false, }, ], - name: undefined, }, ]); }); @@ -798,9 +824,10 @@ string function # The line in the file the message came from. uint32 line`; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { type: "uint8", @@ -901,10 +928,11 @@ MSG: std_msgs/msg/Header builtin_interfaces/msg/Time stamp string frame_id `, - { ros2: true }, + { ros2: true, topLevelTypeName: "Dummy" }, ), ).toEqual([ { + name: "Dummy", definitions: [ { isArray: false, @@ -919,9 +947,9 @@ string frame_id type: "rosbridge_msgs/msg/ConnectedClient", }, ], - name: undefined, }, { + name: "rosbridge_msgs/msg/ConnectedClient", definitions: [ { isArray: false, @@ -937,9 +965,9 @@ string frame_id type: "time", }, ], - name: "rosbridge_msgs/msg/ConnectedClient", }, { + name: "std_msgs/msg/Header", definitions: [ { isArray: false, @@ -955,7 +983,6 @@ string frame_id upperBound: undefined, }, ], - name: "std_msgs/msg/Header", }, ]); }); @@ -976,10 +1003,11 @@ string[<=5] up_to_five_unbounded_strings string<=10[] unbounded_array_of_string_up_to_ten_characters_each string<=10[<=5] up_to_five_strings_up_to_ten_characters_each `, - { ros2: true }, + { ros2: true, topLevelTypeName: "Dummy" }, ), ).toEqual([ { + name: "Dummy", definitions: [ { isArray: true, @@ -1039,13 +1067,15 @@ string<=10[<=5] up_to_five_strings_up_to_ten_characters_each upperBound: 10, }, ], - name: undefined, }, ]); }); it("does not mixup types with same name but different namespace", () => { const messageDefinition = ` + int32 dummy + + === MSG: visualization_msgs/msg/Marker int32 a @@ -1061,8 +1091,19 @@ string<=10[<=5] up_to_five_strings_up_to_ten_characters_each MSG: aruco_msgs/msg/MarkerArray Marker[] b `; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ + { + name: "Dummy", + definitions: [ + { + type: "int32", + isArray: false, + name: "dummy", + isComplex: false, + }, + ], + }, { name: "visualization_msgs/msg/Marker", definitions: [ @@ -1128,9 +1169,10 @@ string<=10[<=5] up_to_five_strings_up_to_ten_characters_each uint64 u `; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { type: "foo_msgs/msg/TypeA", @@ -1182,9 +1224,10 @@ string<=10[<=5] up_to_five_strings_up_to_ten_characters_each const messageDefinition = ` byte[<=3] byte_values_default [0, 1, 255] `; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); expect(types).toEqual([ { + name: "Dummy", definitions: [ { type: "uint8", @@ -1198,4 +1241,226 @@ string<=10[<=5] up_to_five_strings_up_to_ten_characters_each }, ]); }); + + describe("enum inference", () => { + it("handles various constant types", () => { + expect( + parse( + ` + uint32 OFF=0 + uint32 ON=1 + uint32 state + uint8 RED=0 + uint8 YELLOW=1 + uint8 GREEN=2 + uint8 color + uint64 ONE=1 + uint64 TWO=2 + uint64 large_number + + === + MSG: my_msgs/NestedMsg + string FOO=foo + string BAR=bar + string str + bool YEP=True + bool NOPE=False + bool maybe + `, + { ros2: true, topLevelTypeName: "Dummy" }, + ), + ).toEqual([ + { + name: "Dummy", + definitions: [ + { type: "uint32", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint32", name: "ON", isConstant: true, value: 1, valueText: "1" }, + { + type: "uint32", + name: "state", + isArray: false, + isComplex: false, + enumType: "enum for Dummy.state", + }, + { type: "uint8", name: "RED", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "YELLOW", isConstant: true, value: 1, valueText: "1" }, + { type: "uint8", name: "GREEN", isConstant: true, value: 2, valueText: "2" }, + { + type: "uint8", + name: "color", + isArray: false, + isComplex: false, + enumType: "enum for Dummy.color", + }, + { type: "uint64", name: "ONE", isConstant: true, value: 1n, valueText: "1" }, + { type: "uint64", name: "TWO", isConstant: true, value: 2n, valueText: "2" }, + { + type: "uint64", + name: "large_number", + isArray: false, + isComplex: false, + enumType: "enum for Dummy.large_number", + }, + ], + }, + { + name: "my_msgs/NestedMsg", + definitions: [ + { type: "string", name: "FOO", isConstant: true, value: "foo", valueText: "foo" }, + { type: "string", name: "BAR", isConstant: true, value: "bar", valueText: "bar" }, + { + type: "string", + name: "str", + isArray: false, + isComplex: false, + enumType: "enum for my_msgs/NestedMsg.str", + }, + { type: "bool", name: "YEP", isConstant: true, value: true, valueText: "True" }, + { type: "bool", name: "NOPE", isConstant: true, value: false, valueText: "False" }, + { + type: "bool", + name: "maybe", + isArray: false, + isComplex: false, + enumType: "enum for my_msgs/NestedMsg.maybe", + }, + ], + }, + { + name: "enum for Dummy.state", + definitions: [ + { type: "uint32", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint32", name: "ON", isConstant: true, value: 1, valueText: "1" }, + ], + }, + { + name: "enum for Dummy.color", + definitions: [ + { type: "uint8", name: "RED", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "YELLOW", isConstant: true, value: 1, valueText: "1" }, + { type: "uint8", name: "GREEN", isConstant: true, value: 2, valueText: "2" }, + ], + }, + { + name: "enum for Dummy.large_number", + definitions: [ + { type: "uint64", name: "ONE", isConstant: true, value: 1n, valueText: "1" }, + { type: "uint64", name: "TWO", isConstant: true, value: 2n, valueText: "2" }, + ], + }, + { + name: "enum for my_msgs/NestedMsg.str", + definitions: [ + { type: "string", name: "FOO", isConstant: true, value: "foo", valueText: "foo" }, + { type: "string", name: "BAR", isConstant: true, value: "bar", valueText: "bar" }, + ], + }, + { + name: "enum for my_msgs/NestedMsg.maybe", + definitions: [ + { type: "bool", name: "YEP", isConstant: true, value: true, valueText: "True" }, + { type: "bool", name: "NOPE", isConstant: true, value: false, valueText: "False" }, + ], + }, + ]); + }); + + it("handles multiple blocks of constants of the same type", () => { + expect( + parse( + ` + uint8 OFF=0 + uint8 ON=1 + uint8 state1 + uint8 FOO=0 + uint8 BAR=1 + uint8 state2 + `, + { ros2: true, topLevelTypeName: "Dummy" }, + ), + ).toEqual([ + { + name: "Dummy", + definitions: [ + { type: "uint8", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "ON", isConstant: true, value: 1, valueText: "1" }, + { + type: "uint8", + name: "state1", + isArray: false, + isComplex: false, + enumType: "enum for Dummy.state1", + }, + { type: "uint8", name: "FOO", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "BAR", isConstant: true, value: 1, valueText: "1" }, + { + type: "uint8", + name: "state2", + isArray: false, + isComplex: false, + enumType: "enum for Dummy.state2", + }, + ], + }, + { + name: "enum for Dummy.state1", + definitions: [ + { type: "uint8", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "ON", isConstant: true, value: 1, valueText: "1" }, + ], + }, + { + name: "enum for Dummy.state2", + definitions: [ + { type: "uint8", name: "FOO", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "BAR", isConstant: true, value: 1, valueText: "1" }, + ], + }, + ]); + }); + + it("only assigns constants to matching types", () => { + expect( + parse( + ` + uint8 OFF=0 + uint8 ON=1 + uint32 state32 + uint8 state8 + `, + { ros2: true, topLevelTypeName: "Dummy" }, + ), + ).toEqual([ + { + name: "Dummy", + definitions: [ + { type: "uint8", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "ON", isConstant: true, value: 1, valueText: "1" }, + { type: "uint32", name: "state32", isArray: false, isComplex: false }, + { type: "uint8", name: "state8", isArray: false, isComplex: false }, + ], + }, + // no enums inferred as the first type after constants doesn't match constant type + ]); + }); + + it("skips enums if requested", () => { + expect( + parse("uint8 OFF=0\nuint8 ON=1\nuint8 state", { + ros2: true, + topLevelTypeName: "Dummy", + skipEnums: true, + }), + ).toEqual([ + { + name: "Dummy", + definitions: [ + { type: "uint8", name: "OFF", isConstant: true, value: 0, valueText: "0" }, + { type: "uint8", name: "ON", isConstant: true, value: 1, valueText: "1" }, + { type: "uint8", name: "state", isArray: false, isComplex: false }, + ], + }, + ]); + }); + }); }); diff --git a/packages/rosmsg/src/parse.ts b/packages/rosmsg/src/parse.ts index 54116d17..2b41aca9 100644 --- a/packages/rosmsg/src/parse.ts +++ b/packages/rosmsg/src/parse.ts @@ -16,17 +16,30 @@ import { Grammar, Parser } from "nearley"; import { buildRos2Type } from "./buildRos2Type"; import ros1Rules from "./ros1.ne"; +import { NamedMessageDefinition } from "./types"; const ROS1_GRAMMAR = Grammar.fromCompiled(ros1Rules); export type ParseOptions = { - /** Parse message definitions as ROS 2. Otherwise, parse as ROS1 */ + /** The name for the message definition being parsed. */ + topLevelTypeName: string; + /** + * Parse message definitions as ROS 2. Otherwise, parse as ROS1 + * @default false + */ ros2?: boolean; /** * Return the original type names used in the file, without normalizing to * fully qualified type names + * @default false */ skipTypeFixup?: boolean; + /** + * Disable pseudo-definitions for auto-detected enums, based on matching constant + * fields with non-constant fields. + * @default false + */ + skipEnums?: boolean; }; // Given a raw message definition string, parse it into an object representation. @@ -46,7 +59,7 @@ export type ParseOptions = { // }, ... ] // // See unit tests for more examples. -export function parse(messageDefinition: string, options: ParseOptions = {}): MessageDefinition[] { +export function parse(messageDefinition: string, options: ParseOptions): NamedMessageDefinition[] { // read all the lines and remove empties const allLines = messageDefinition .split("\n") @@ -55,30 +68,34 @@ export function parse(messageDefinition: string, options: ParseOptions = {}): Me .filter((line) => line); let definitionLines: { line: string }[] = []; - const types: MessageDefinition[] = []; + const types: NamedMessageDefinition[] = []; + let isTopLevelType = true; + // group lines into individual definitions - allLines.forEach((line) => { + for (const line of allLines) { // ignore comment lines if (line.startsWith("#")) { - return; + continue; } // definitions are split by equal signs if (line.startsWith("==")) { types.push( options.ros2 === true - ? buildRos2Type(definitionLines) - : buildType(definitionLines, ROS1_GRAMMAR), + ? buildRos2Type(definitionLines, isTopLevelType ? options.topLevelTypeName : undefined) + : buildRos1Type(definitionLines, isTopLevelType ? options.topLevelTypeName : undefined), ); + isTopLevelType = false; definitionLines = []; } else { definitionLines.push({ line }); } - }); + } + types.push( options.ros2 === true - ? buildRos2Type(definitionLines) - : buildType(definitionLines, ROS1_GRAMMAR), + ? buildRos2Type(definitionLines, isTopLevelType ? options.topLevelTypeName : undefined) + : buildRos1Type(definitionLines, isTopLevelType ? options.topLevelTypeName : undefined), ); // Filter out duplicate types to handle the case where schemas are erroneously duplicated @@ -97,6 +114,10 @@ export function parse(messageDefinition: string, options: ParseOptions = {}): Me fixupTypes(uniqueTypes); } + if (options.skipEnums !== true) { + inferEnums(uniqueTypes); + } + return uniqueTypes; } @@ -105,8 +126,8 @@ export function parse(messageDefinition: string, options: ParseOptions = {}): Me * Example: `Marker` (defined in `visualization_msgs/MarkerArray` message) becomes `visualization_msgs/Marker`. */ export function fixupTypes(types: MessageDefinition[]): void { - types.forEach(({ definitions, name }) => { - definitions.forEach((definition) => { + for (const { definitions, name } of types) { + for (const definition of definitions) { if (definition.isComplex === true) { // The type might be under a namespace (e.g. std_msgs or std_msgs/msg) which is required // to uniquely retrieve the type by its name. @@ -117,21 +138,32 @@ export function fixupTypes(types: MessageDefinition[]): void { } definition.type = foundName; } - }); - }); + } + } } -function buildType(lines: { line: string }[], grammar: Grammar): MessageDefinition { +/** + * @param topLevelTypeName Required if this is a top-level type that does not contain a "MSG:" line. + */ +function buildRos1Type( + lines: { line: string }[], + topLevelTypeName?: string, +): NamedMessageDefinition { const definitions: MessageDefinitionField[] = []; - let complexTypeName: string | undefined; - lines.forEach(({ line }) => { + let complexTypeName = topLevelTypeName; + for (const { line } of lines) { if (line.startsWith("MSG:")) { const [_, name] = simpleTokenization(line); + if (complexTypeName != undefined) { + throw new Error( + `Unexpected MSG name in top-level type: ${complexTypeName}, ${name ?? "(could not parse name)"}`, + ); + } complexTypeName = name?.trim(); - return; + continue; } - const parser = new Parser(grammar); + const parser = new Parser(ROS1_GRAMMAR); parser.feed(line); const results = parser.finish() as MessageDefinitionField[]; if (results.length === 0) { @@ -144,7 +176,10 @@ function buildType(lines: { line: string }[], grammar: Grammar): MessageDefiniti result.type = normalizeType(result.type); definitions.push(result); } - }); + } + if (complexTypeName == undefined) { + throw new Error("Missing name for top-level type definition"); + } return { name: complexTypeName, definitions }; } @@ -205,3 +240,47 @@ export function normalizeType(type: string): string { } return type; } + +/** + * Although ROS does not natively support enums, we can infer them by looking at constant fields in + * the message definitions. We treat constants preceding a field with a matching type as though they + * defined an enum for that field. + */ +function inferEnums(types: NamedMessageDefinition[]) { + const existingTypeNames = new Set(types.map((type) => type.name)); + for (const { name: typeName, definitions } of types) { + let currentConstants: MessageDefinitionField[] = []; + let currentType: string | undefined; + + for (const field of definitions) { + if (currentType != undefined && field.type !== currentType) { + // encountering new type resets the accumulated constants + currentConstants = []; + currentType = undefined; + } + + if (field.isConstant === true && field.value != undefined) { + currentType = field.type; + currentConstants.push(field); + continue; + } + + // otherwise assign accumulated constants for that field + if (currentConstants.length > 0) { + const enumName = `enum for ${typeName}.${field.name}`; + if (existingTypeNames.has(enumName)) { + throw new Error(`Unable to infer "${enumName}" due to existing type with this name`); + } + if (field.enumType != undefined) { + throw new Error(`Invariant: expected field not to have an enumType yet`); + } + field.enumType = enumName; + types.push({ name: enumName, definitions: currentConstants }); + existingTypeNames.add(enumName); + } + + // and start over - reset constants + currentConstants = []; + } + } +} diff --git a/packages/rosmsg/src/stringify.test.ts b/packages/rosmsg/src/stringify.test.ts index 91255ca1..cb625a91 100644 --- a/packages/rosmsg/src/stringify.test.ts +++ b/packages/rosmsg/src/stringify.test.ts @@ -17,7 +17,7 @@ describe("stringify", () => { MSG: geometry_msgs/Point float64 x `; - const types = parse(messageDefinition); + const types = parse(messageDefinition, { topLevelTypeName: "Dummy" }); const output = stringify(types); expect(output).toEqual(`uint32 foo = 55 @@ -59,7 +59,7 @@ float64 x`); string my_string3 'I heard \\'Hello\\'' # is valid string my_string4 'I heard "Hello"' # is valid `; - const types = parse(messageDefinition, { ros2: true }); + const types = parse(messageDefinition, { ros2: true, topLevelTypeName: "Dummy" }); const output = stringify(types); expect(output).toEqual(`string<=5 str1 "abc" diff --git a/packages/rosmsg/src/types.ts b/packages/rosmsg/src/types.ts new file mode 100644 index 00000000..4d0b632d --- /dev/null +++ b/packages/rosmsg/src/types.ts @@ -0,0 +1,6 @@ +import { MessageDefinition } from "@foxglove/message-definition"; + +/** A message definition whose name is known */ +export type NamedMessageDefinition = MessageDefinition & { + name: NonNullable; +}; diff --git a/yarn.lock b/yarn.lock index ddf71f9b..67b204c1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -724,20 +724,6 @@ __metadata: languageName: node linkType: hard -"@foxglove/message-definition@npm:^0.2.0": - version: 0.2.0 - resolution: "@foxglove/message-definition@npm:0.2.0" - checksum: 10c0/d9b873ae2b358882a58abab5d081fd37cb63e8c9005f333f5b635019eebe2b73e5d805797c79c2e3aef5acf6c80efd585c8be212c9a8527c121d5fd5b61f100d - languageName: node - linkType: hard - -"@foxglove/message-definition@npm:^0.3.1": - version: 0.3.1 - resolution: "@foxglove/message-definition@npm:0.3.1" - checksum: 10c0/521fadfadcdb9bbde7d28908e429b93a387ded524f8abeb361f27ae5a6f36b8d5dc29cf23dbf0791cc366de59c48dc03aede861faa59df1c64d030b6c659981a - languageName: node - linkType: hard - "@foxglove/message-definition@npm:^0.4.0": version: 0.4.0 resolution: "@foxglove/message-definition@npm:0.4.0" @@ -758,7 +744,7 @@ __metadata: version: 0.0.0-use.local resolution: "@foxglove/ros1@workspace:packages/ros1" dependencies: - "@foxglove/message-definition": "npm:^0.2.0" + "@foxglove/message-definition": "npm:^0.4.0" "@foxglove/rosmsg": "workspace:^" "@foxglove/rosmsg-serialization": "workspace:^" "@foxglove/xmlrpc": "workspace:^" @@ -868,7 +854,7 @@ __metadata: version: 0.0.0-use.local resolution: "@foxglove/rosmsg-msgs-common@workspace:packages/rosmsg-msgs-common" dependencies: - "@foxglove/message-definition": "npm:^0.3.1" + "@foxglove/message-definition": "npm:^0.4.0" "@foxglove/rosmsg": "workspace:^" "@types/node": "npm:^22.10.5" esbuild: "npm:0.24.2" @@ -882,7 +868,7 @@ __metadata: version: 0.0.0-use.local resolution: "@foxglove/rosmsg-serialization@workspace:packages/rosmsg-serialization" dependencies: - "@foxglove/message-definition": "npm:^0.3.1" + "@foxglove/message-definition": "npm:^0.4.0" "@foxglove/rosmsg": "workspace:*" "@foxglove/tsconfig": "npm:2.0.0" "@types/jest": "npm:29.5.14" @@ -922,7 +908,7 @@ __metadata: version: 0.0.0-use.local resolution: "@foxglove/rosmsg@workspace:packages/rosmsg" dependencies: - "@foxglove/message-definition": "npm:^0.3.1" + "@foxglove/message-definition": "npm:^0.4.0" "@types/jest": "npm:29.5.14" "@types/nearley": "npm:2.11.2" jest: "npm:29.7.0"