From 816b5be9ba581eb1cc46a6ebcc691235a5daf63c Mon Sep 17 00:00:00 2001 From: Kenny Van de Maele Date: Thu, 15 Oct 2026 01:25:04 +0200 Subject: [PATCH] feat: edits to mission source for the editor's forms and map The editor's record forms and Scene view change records in the source as they are edited, so the source keeps its comments and layout instead of being written again from the mission. Each node of the syntax tree now has where it ends, from Luau's parser, and dte.source.edit makes the edits: set an argument, set or remove a field of a declaration's table, and add a declaration. language.fieldsOf gives the table of fields each declaration takes, for a form, and ShipReached's reach is public for the map to draw. Closes #1210 --- docs/formats/mission-source.md | 27 +- src/engine/game/mission/events.zig | 5 +- src/formats/dte/source.zig | 2 + src/formats/dte/source/edit.zig | 367 ++++++++++++++++++++++++++++ src/formats/dte/source/language.zig | 40 +++ src/formats/dte/source/syntax.zig | 47 ++-- src/formats/dte/source/write.zig | 12 +- src/source_parser.zig | 97 ++++++++ src/source_parser/luau.cpp | 8 +- src/source_parser/luau.h | 5 +- 10 files changed, 576 insertions(+), 34 deletions(-) create mode 100644 src/formats/dte/source/edit.zig diff --git a/docs/formats/mission-source.md b/docs/formats/mission-source.md index 0c8ea00c..52356558 100644 --- a/docs/formats/mission-source.md +++ b/docs/formats/mission-source.md @@ -339,14 +339,35 @@ trigger's condition: The reference lists each condition's values ([Conditions](../guide/mission-reference.md#conditions)). +### Edits + +An editor that changes records through forms, such as the +[mission editor](https://github.com/OpenReliant/openreliant-editor), edits the source with +[`edit.zig`](../../src/formats/dte/source/edit.zig) instead of writing the whole file again, so the +rest of the text keeps its comments and layout. Each edit replaces one span of the text, the way an +undoable text editor replaces it: + +- `setArgument` sets an argument of a declaration, such as a ship's display name. +- `setField` sets a field of a declaration's table. It replaces the value if the table has the + field, and otherwise adds the field after the last item: on a new line with the same indent if + the table spans several lines, and on the same line if not. A call without a table gets one, + before a trigger's function. +- `removeField` removes a field. A field alone on its line goes with the line and its comment. +- `declare` adds a declaration on a new line, such as after the last one with the same word. + +Values are written the way the decompiler writes them. `language.fieldsOf` gives the table of +fields that each declaration takes and which argument it is, and the table gives each field's type +and description for a form. + ## Reading the text Luau's own parser reads mission source, so the syntax is exactly Luau's, comments included. [`src/source_parser.zig`](../../src/source_parser.zig) calls the parser through a C++ shim, since -it has no C API, and copies its syntax tree into the library's +the parser has no C API, and copies its syntax tree into the library's ([`syntax.zig`](../../src/formats/dte/source/syntax.zig)): each statement and expression with the -line and column it starts at, and each error the parser finds with its place. Only the tools that -read source link the parser. sltool does; the library the tools share doesn't. +line and column where it starts and ends, and each error the parser finds with its place. Columns +count bytes, as in Luau. A table's item starts at its key and ends with its value. Only the tools +that read source link the parser: sltool does, and the library that the tools share doesn't. Mission source uses a subset of Luau. The parser gives back the rest, such as a loop, a function declared by name, a method call, an `if` expression or a string with values put into it, as an diff --git a/src/engine/game/mission/events.zig b/src/engine/game/mission/events.zig index b8e8beea..c6810669 100644 --- a/src/engine/game/mission/events.zig +++ b/src/engine/game/mission/events.zig @@ -317,8 +317,9 @@ fn firstArmed(list: []const Watch, object: u16) ?Watch { /// How far CloseProximity's watches look, in the subject's radii (`0x004DC72C`). const close_reach: f32 = 20; -/// How far ShipReached's watches look (`0x4B742400`, its square). -const reached_reach: f32 = 4000; +/// How far ShipReached's watches look (`0x4B742400`, its square), which an editor's map draws +/// around a waypoint or a nav point. +pub const reached_reach: f32 = 4000; /// The operand of a Proximity trigger that gives its distance, in the subject's radii. const proximity_operand = 1; diff --git a/src/formats/dte/source.zig b/src/formats/dte/source.zig index 6df1b128..31a79978 100644 --- a/src/formats/dte/source.zig +++ b/src/formats/dte/source.zig @@ -15,6 +15,8 @@ pub const write = @import("source/write.zig"); pub const syntax = @import("source/syntax.zig"); /// The text compiled into a mission file. pub const compile = @import("source/compile.zig"); +/// Edits to the text as an editor makes them. +pub const edit = @import("source/edit.zig"); /// What the source's words take and give, and the fields of its tables. pub const language = @import("source/language.zig"); /// The definitions file for luau-lsp. diff --git a/src/formats/dte/source/edit.zig b/src/formats/dte/source/edit.zig new file mode 100644 index 00000000..d87e985e --- /dev/null +++ b/src/formats/dte/source/edit.zig @@ -0,0 +1,367 @@ +//! The edits an editor makes to mission source +//! ([Editors](../../../../docs/formats/mission-source.md#editors)): setting an argument of a +//! declaration, setting, adding or removing a field of its table, and adding a declaration. Each +//! edit replaces one span of the source, so the rest of the text keeps its comments and layout. +//! Values are written the way the decompiler writes them (`write.zig`). + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const Writer = std.Io.Writer; + +const script = @import("script.zig"); +const syntax = @import("syntax.zig"); +const write = @import("write.zig"); +const Index = syntax.Index; +const Position = syntax.Position; + +/// Text that replaces the source from `start` up to `end`. An edit that only inserts text has the +/// same `start` and `end`. +pub const Edit = struct { + start: Position, + end: Position, + text: []const u8, +}; + +/// A value an edit writes. +pub const Value = union(enum) { + number: f32, + vector: [3]f32, + /// A string. + text: []const u8, + /// A name, such as a declared record's, written as it is. + name: []const u8, + boolean: bool, + /// A table, on one line. + table: []const Item, + /// Source written as it is, such as `sabres[2]`. + source: []const u8, + + /// An item of a table: `key = value`, or just a value if it has no key. + pub const Item = struct { + key: ?[]const u8 = null, + value: Value, + + pub fn format(item: Item, w: *Writer) Writer.Error!void { + if (item.key) |key| try w.print("{s} = ", .{key}); + try w.print("{f}", .{item.value}); + } + }; + + pub fn format(value: Value, w: *Writer) Writer.Error!void { + switch (value) { + .number => |number| try w.print("{f}", .{write.number(number)}), + .vector => |vector| try w.print("{f}", .{write.vector(vector)}), + .text => |text| try script.writeString(w, text), + .name, .source => |text| try w.writeAll(text), + .boolean => |boolean| try w.writeAll(if (boolean) "true" else "false"), + .table => |items| { + if (items.len == 0) return w.writeAll("{}"); + try w.writeAll("{ "); + for (items, 0..) |item, index| try w.print("{s}{f}", .{ if (index > 0) ", " else "", item }); + try w.writeAll(" }"); + }, + } + } +}; + +/// Mission source as an editor edits it: the text, the tree that Luau's parser reads from it, and +/// where each line starts. Lines and columns count from 0, and columns count bytes, as in Luau. +pub const Source = struct { + text: []const u8, + tree: syntax.Tree, + line_starts: []const usize, + + /// The source of `text`, with `tree` as `source_parser.parse` reads it from `text`. + pub fn init(arena: Allocator, text: []const u8, tree: syntax.Tree) Allocator.Error!Source { + var starts: std.ArrayList(usize) = .empty; + try starts.append(arena, 0); + for (text, 0..) |byte, index| if (byte == '\n') try starts.append(arena, index + 1); + return .{ .text = text, .tree = tree, .line_starts = starts.items }; + } + + /// The call that declares `name` (`name = word(...)`), or null if the source doesn't declare + /// it. + pub fn declaration(source: Source, name: []const u8) ?Index { + for (source.statements()) |statement| { + const call = source.declared(statement) orelse continue; + if (std.mem.eql(u8, call.name, name)) return call.call; + } + return null; + } + + /// The call of the statement that starts on `line`: a declaration's call, or a call made as a + /// statement, such as a trigger's `on(...)`. + pub fn callOn(source: Source, line: u32) ?Index { + for (source.statements()) |statement| { + if (source.tree.at(statement).line != line) continue; + if (source.declared(statement)) |found| return found.call; + return switch (source.tree.get(statement)) { + .call_statement => |call| call, + else => null, + }; + } + return null; + } + + /// The last statement that declares a record with `word` (`name = word(...)`), if there is + /// one. + pub fn lastDeclarationOf(source: Source, word: []const u8) ?Index { + var last: ?Index = null; + for (source.statements()) |statement| { + const found = source.declared(statement) orelse continue; + const called = source.callee(found.call) orelse continue; + if (std.mem.eql(u8, called, word)) last = statement; + } + return last; + } + + /// The name that `call` calls, such as `ship`, or null if it calls something other than a + /// name. + pub fn callee(source: Source, call: Index) ?[]const u8 { + return switch (source.tree.get(call)) { + .call => |found| switch (source.tree.get(found.function)) { + .global_name => |name| name, + else => null, + }, + else => null, + }; + } + + /// The edit that sets argument `index` of `call` to `value`. If the call has exactly `index` + /// arguments, the value is added after the last one. Null if the call has fewer arguments. + pub fn setArgument(source: Source, arena: Allocator, call: Index, index: usize, value: Value) Allocator.Error!?Edit { + const arguments = source.argumentsOf(call) orelse return null; + if (index < arguments.len) return try source.replace(arena, arguments[index], value); + if (index == arguments.len) return try source.append(arena, call, try arena.print("{f}", .{value})); + return null; + } + + /// The edit that sets `field` to `value` in the table that is argument `index` of `call`. If + /// the table has the field, its value is replaced. Otherwise the field is added after the last + /// item: on a new line if the table spans several lines, and on the same line if not. If + /// argument `index` isn't a table, a table with the field is added before it, such as before + /// a trigger's function, or after the last argument if the call has exactly `index` + /// arguments. Null if the call has fewer arguments. + pub fn setField(source: Source, arena: Allocator, call: Index, index: usize, field: []const u8, value: Value) Allocator.Error!?Edit { + const arguments = source.argumentsOf(call) orelse return null; + const item = try arena.print("{s} = {f}", .{ field, value }); + if (index == arguments.len) return try source.append(arena, call, try arena.print("{{ {s} }}", .{item})); + if (index > arguments.len) return null; + const table = arguments[index]; + const items = switch (source.tree.get(table)) { + .table => |items| items, + else => return try insert(arena, source.tree.at(table), "{{ {s} }}, ", .{item}), + }; + if (source.itemIndex(items, field)) |found| return try source.replace(arena, source.valueOf(items[found]), value); + if (items.len == 0) return .{ .start = source.tree.at(table), .end = source.tree.end(table), .text = try arena.print("{{ {s} }}", .{item}) }; + const last = items[items.len - 1]; + const last_end = source.offset(source.tree.end(last)); + if (source.tree.at(last).line > source.tree.at(table).line and source.endsLine(last_end)) { + // On a new line after the last item, with the same indent, and with a comma after the + // last item if it has none. + const line_end = source.lineEnd(last_end); + const rest = source.text[last_end..line_end]; + const comma = if (std.mem.startsWith(u8, std.mem.trimStart(u8, rest, " \t"), ",")) "" else ","; + return .{ + .start = source.tree.end(last), + .end = source.positionOf(line_end), + .text = try arena.print("{s}{s}\n{s}{s},", .{ comma, rest, source.indentOf(source.tree.at(last).line), item }), + }; + } + // On the last item's line: after its comma if it has one, and otherwise after the item. + const after = source.text[last_end..]; + const spaces = after.len - std.mem.trimStart(u8, after, " \t").len; + if (last_end + spaces < source.text.len and source.text[last_end + spaces] == ',') { + return try insert(arena, source.positionOf(last_end + spaces + 1), " {s},", .{item}); + } + return try insert(arena, source.tree.end(last), ", {s}", .{item}); + } + + /// The edit that removes `field` from the table that is argument `index` of `call`. An item + /// that is alone on its lines goes with its lines, comment included, and any other item with + /// the comma between it and its neighbour. Removing the only item leaves `{}`. Null if the + /// table doesn't have the field. + pub fn removeField(source: Source, call: Index, index: usize, field: []const u8) ?Edit { + const arguments = source.argumentsOf(call) orelse return null; + if (index >= arguments.len) return null; + const table = arguments[index]; + const items = switch (source.tree.get(table)) { + .table => |items| items, + else => return null, + }; + const place = source.itemIndex(items, field) orelse return null; + if (items.len == 1) return .{ .start = source.tree.at(table), .end = source.tree.end(table), .text = "{}" }; + const item = items[place]; + if (source.tree.at(item).line > source.tree.at(table).line and source.aloneOnLines(item)) { + const last_line = source.tree.end(item).line; + return .{ .start = .{ .line = source.tree.at(item).line, .column = 0 }, .end = source.positionOf(source.lineStart(last_line + 1)), .text = "" }; + } + if (place + 1 < items.len) return .{ .start = source.tree.at(item), .end = source.tree.at(items[place + 1]), .text = "" }; + return .{ .start = source.tree.end(items[place - 1]), .end = source.tree.end(item), .text = "" }; + } + + /// The edit that adds the declaration `name = word(values)` on a new line after the statement + /// `after`, or at the end of the text if `after` is null. If the last argument is a table and + /// the line would be longer than `write.line_length`, each of its items goes on a line of its + /// own. + pub fn declare(source: Source, arena: Allocator, after: ?Index, name: []const u8, word: []const u8, values: []const Value) Allocator.Error!Edit { + var line: Writer.Allocating = .init(arena); + line.writer.print("{s} = {s}(", .{ name, word }) catch return error.OutOfMemory; + for (values, 0..) |argument, index| { + if (index > 0) line.writer.writeAll(", ") catch return error.OutOfMemory; + const last_table = index + 1 == values.len and argument == .table; + if (!last_table) { + line.writer.print("{f}", .{argument}) catch return error.OutOfMemory; + continue; + } + const items = try arena.alloc([]const u8, argument.table.len); + for (items, argument.table) |*text, item| text.* = try arena.print("{f}", .{item}); + write.writeTable(&line.writer, items, line.written().len, ")".len) catch return error.OutOfMemory; + } + line.writer.writeAll(")\n") catch return error.OutOfMemory; + const at = if (after) |statement| source.lineStart(source.tree.end(statement).line + 1) else source.text.len; + // At the end of a text whose last line has no line break, the declaration starts one. + const lead = if (at == source.text.len and at > 0 and source.text[at - 1] != '\n') "\n" else ""; + return insert(arena, source.positionOf(at), "{s}{s}", .{ lead, line.written() }); + } + + /// The text after `edit`, allocated in `arena`. + pub fn apply(source: Source, arena: Allocator, edit: Edit) Allocator.Error![]u8 { + const start = source.offset(edit.start); + const end = source.offset(edit.end); + return std.mem.concat(arena, u8, &.{ source.text[0..start], edit.text, source.text[end..] }); + } + + fn statements(source: Source) []const Index { + return switch (source.tree.get(source.tree.root)) { + .block => |list| list, + else => &.{}, + }; + } + + /// The name and the call of a declaration, `name = word(...)`. + fn declared(source: Source, statement: Index) ?struct { name: []const u8, call: Index } { + const assign = switch (source.tree.get(statement)) { + .assign => |assign| assign, + else => return null, + }; + if (assign.targets.len != 1 or assign.values.len != 1) return null; + const name = switch (source.tree.get(assign.targets[0])) { + .global_name => |name| name, + else => return null, + }; + if (source.tree.get(assign.values[0]) != .call) return null; + return .{ .name = name, .call = assign.values[0] }; + } + + fn argumentsOf(source: Source, call: Index) ?[]const Index { + return switch (source.tree.get(call)) { + .call => |found| found.arguments, + else => null, + }; + } + + /// The index in `items` of the item `field = value`. + fn itemIndex(source: Source, items: []const Index, field: []const u8) ?usize { + for (items, 0..) |item, index| switch (source.tree.get(item)) { + .item => |found| switch (found) { + .record => |record| if (std.mem.eql(u8, record.key, field)) return index, + else => {}, + }, + else => {}, + }; + return null; + } + + fn valueOf(source: Source, item: Index) Index { + return switch (source.tree.get(item).item) { + .list => |value| value, + .record => |record| record.value, + .general => |general| general.value, + }; + } + + /// The edit that replaces `node` with `value`. + fn replace(source: Source, arena: Allocator, node: Index, value: Value) Allocator.Error!Edit { + return .{ .start = source.tree.at(node), .end = source.tree.end(node), .text = try arena.print("{f}", .{value}) }; + } + + /// The edit that adds `text` as the last argument of `call`. + fn append(source: Source, arena: Allocator, call: Index, text: []const u8) Allocator.Error!Edit { + const list = source.argumentsOf(call) orelse unreachable; + if (list.len > 0) return insert(arena, source.tree.end(list[list.len - 1]), ", {s}", .{text}); + // Before the call's closing bracket. + const end = source.tree.end(call); + return insert(arena, .{ .line = end.line, .column = end.column -| 1 }, "{s}", .{text}); + } + + /// Whether `item` is alone on its lines: only spaces before it on its first line, and only a + /// comma, spaces and a comment after it on its last. + fn aloneOnLines(source: Source, item: Index) bool { + const start = source.tree.at(item); + const before = source.text[source.lineStart(start.line)..source.offset(start)]; + return std.mem.trim(u8, before, " \t").len == 0 and source.endsLine(source.offset(source.tree.end(item))); + } + + /// Whether only a comma, spaces and a comment follow byte `index` on its line. + fn endsLine(source: Source, index: usize) bool { + var rest = std.mem.trim(u8, source.text[index..source.lineEnd(index)], " \t\r"); + if (std.mem.startsWith(u8, rest, ",")) rest = std.mem.trimStart(u8, rest[1..], " \t"); + return rest.len == 0 or std.mem.startsWith(u8, rest, "--"); + } + + /// Where the line of byte `index` ends: at its line break, or at the end of the text. + fn lineEnd(source: Source, index: usize) usize { + return std.mem.findScalarPos(u8, source.text, index, '\n') orelse source.text.len; + } + + /// The spaces and tabs at the start of line `line`. + fn indentOf(source: Source, line: u32) []const u8 { + const start = source.lineStart(line); + var end = start; + while (end < source.text.len and (source.text[end] == ' ' or source.text[end] == '\t')) end += 1; + return source.text[start..end]; + } + + /// Where line `line` starts in the text, or the end of the text past the last line. + fn lineStart(source: Source, line: u32) usize { + return if (line < source.line_starts.len) source.line_starts[line] else source.text.len; + } + + fn offset(source: Source, position: Position) usize { + return @min(source.text.len, source.lineStart(position.line) + position.column); + } + + /// The line and column of byte `index` of the text. + fn positionOf(source: Source, index: usize) Position { + var line: usize = std.sort.upperBound(usize, source.line_starts, index, struct { + fn order(wanted: usize, start: usize) std.math.Order { + return std.math.order(wanted, start); + } + }.order); + line -|= 1; + return .{ .line = @intCast(line), .column = @intCast(index - source.line_starts[line]) }; + } +}; + +/// The edit that inserts `format` with `args` at `at`. +fn insert(arena: Allocator, at: Position, comptime format: []const u8, args: anytype) Allocator.Error!Edit { + return .{ .start = at, .end = at, .text = try arena.print(format, args) }; +} + +test Value { + var buffer: [128]u8 = undefined; + var w: Writer = .fixed(&buffer); + const values = [_]Value{ + .{ .number = 0.5 }, + .{ .vector = .{ -2000, 0, 42000 } }, + .{ .text = "Sabre \"1\"" }, + .{ .name = "alpha" }, + .{ .boolean = false }, + .{ .table = &.{} }, + .{ .table = &.{ .{ .key = "from", .value = .{ .name = "carrier" } }, .{ .value = .{ .number = 2 } } } }, + .{ .source = "sabres[2]" }, + }; + for (values) |value| try w.print("{f}; ", .{value}); + try std.testing.expectEqualStrings("0.5; { -2000, 0, 42000 }; \"Sabre \\\"1\\\"\"; alpha; false; {}; { from = carrier, 2 }; sabres[2]; ", w.buffered()); +} diff --git a/src/formats/dte/source/language.zig b/src/formats/dte/source/language.zig index 7fc8ab45..fcc7f28e 100644 --- a/src/formats/dte/source/language.zig +++ b/src/formats/dte/source/language.zig @@ -235,6 +235,28 @@ pub fn declaration(word: Word) Declaration { }; } +/// The table of fields that a declaration takes, and which of its arguments it is. +pub const FieldsArgument = struct { + index: usize, + table: Table, +}; + +/// The table of fields that `word` takes, if it takes one: what an editor's form for the record +/// shows, and where its edits go. +pub fn fieldsOf(word: Word) ?FieldsArgument { + return switch (word) { + .mission => .{ .index = 0, .table = mission_fields }, + .flight_group => .{ .index = 1, .table = flight_group_fields }, + .ship => .{ .index = 1, .table = ship_fields }, + .nav_point, .waypoint, .curve_point => .{ .index = 1, .table = marker_fields }, + .point => .{ .index = 1, .table = point_fields }, + .curve => .{ .index = 0, .table = curve_fields }, + .part => .{ .index = 2, .table = part_fields }, + .on => .{ .index = 2, .table = trigger_fields }, + else => null, + }; +} + /// `word`'s signature, as Luau writes it: `ship(name: string, fields: ShipFields): MissionShip`, /// or `any_ship: MissionShip` for a value. pub const Signature = struct { @@ -375,6 +397,24 @@ test parameters { try std.testing.expectEqualStrings("entity_to_be_controlled: Entity; ai_mode: Order; initialize_immediately: number; entity_to_target: Entity?; ", w.buffered()); } +test fieldsOf { + // A word has a table exactly if it has a `fields` parameter: that parameter, of the table's + // type. + for (std.enums.values(Word)) |word| { + var listed = std.mem.splitSequence(u8, declaration(word).parameters orelse "", ", "); + var index: usize = 0; + const parameter = while (listed.next()) |parameter| : (index += 1) { + if (std.mem.startsWith(u8, parameter, "fields: ")) break parameter; + } else { + try std.testing.expect(fieldsOf(word) == null); + continue; + }; + const fields = fieldsOf(word).?; + try std.testing.expectEqual(index, fields.index); + try std.testing.expect(std.mem.find(u8, parameter, fields.table.name) != null); + } +} + test Signature { var buffer: [256]u8 = undefined; var w: Writer = .fixed(&buffer); diff --git a/src/formats/dte/source/syntax.zig b/src/formats/dte/source/syntax.zig index d3c00e24..7a5b98a9 100644 --- a/src/formats/dte/source/syntax.zig +++ b/src/formats/dte/source/syntax.zig @@ -1,9 +1,9 @@ //! Mission source as Luau's parser reads it //! ([Mission source](../../../../docs/formats/mission-source.md)): a tree of the statements and -//! expressions in the file, each node with the line and column it starts at. The `source_parser` -//! module fills it with Luau's own parser, which only the tools that read source link. A construct -//! mission source doesn't use, such as a loop, is an `unsupported` node, so that the compiler can -//! say where it is. +//! expressions in the file, each node with the line and column where it starts and ends. The +//! `source_parser` module fills it with Luau's own parser, which only the tools that read source +//! link. A construct that mission source doesn't use, such as a loop, is an `unsupported` node, so +//! that the compiler can say where it is. const std = @import("std"); @@ -106,7 +106,8 @@ pub const BinaryOperator = enum(u8) { /// The forms of a table's item: `value`, `key = value` and `[key] = value`. pub const ItemKind = enum(u8) { list, record, general }; -/// Where a node or an error starts, counting lines and columns from 0, as Luau does. +/// A place in the source, where a node or an error starts or a node ends. Lines and columns count +/// from 0, and columns count bytes, as in Luau. pub const Position = extern struct { line: u32, column: u32, @@ -125,6 +126,8 @@ pub const Node = extern struct { /// an assignment's or a function's children are its names, its targets or its parameters. op: u8, at: Position, + /// Just past the node's last byte. + end: Position, /// The node's children, `count` of them from `first` in `Tree.children`. first: u32, count: u32, @@ -291,6 +294,11 @@ pub const Tree = struct { return tree.nodes[index].at; } + /// Where node `index` ends, just past its last byte. A table's item ends with its value. + pub fn end(tree: Tree, index: Index) Position { + return tree.nodes[index].end; + } + fn childrenOf(tree: Tree, index: Index) []const Index { const node = tree.nodes[index]; return tree.children[node.first..][0..node.count]; @@ -328,19 +336,19 @@ pub const Tree = struct { test Tree { // `x += -2` and `on("a", { b = true })`, built by hand as the bridge writes them. const nodes = [_]Node{ - .{ .kind = .global_name, .op = 0, .at = .{ .line = 0, .column = 0 }, .first = 0, .count = 0, .text_start = 0, .text_length = 1, .number = 0 }, - .{ .kind = .number, .op = 0, .at = .{ .line = 0, .column = 6 }, .first = 0, .count = 0, .text_start = 1, .text_length = 0, .number = 2 }, - .{ .kind = .unary, .op = @backingInt(UnaryOperator.minus), .at = .{ .line = 0, .column = 5 }, .first = 0, .count = 1, .text_start = 1, .text_length = 0, .number = 0 }, - .{ .kind = .compound_assign, .op = @backingInt(BinaryOperator.add), .at = .{ .line = 0, .column = 0 }, .first = 1, .count = 2, .text_start = 1, .text_length = 0, .number = 0 }, - .{ .kind = .global_name, .op = 0, .at = .{ .line = 1, .column = 0 }, .first = 3, .count = 0, .text_start = 1, .text_length = 2, .number = 0 }, - .{ .kind = .string, .op = 0, .at = .{ .line = 1, .column = 3 }, .first = 3, .count = 0, .text_start = 3, .text_length = 1, .number = 0 }, - .{ .kind = .string, .op = 0, .at = .{ .line = 1, .column = 10 }, .first = 3, .count = 0, .text_start = 4, .text_length = 1, .number = 0 }, - .{ .kind = .boolean, .op = 1, .at = .{ .line = 1, .column = 14 }, .first = 3, .count = 0, .text_start = 5, .text_length = 0, .number = 0 }, - .{ .kind = .item, .op = @backingInt(ItemKind.record), .at = .{ .line = 1, .column = 10 }, .first = 3, .count = 2, .text_start = 5, .text_length = 0, .number = 0 }, - .{ .kind = .table, .op = 0, .at = .{ .line = 1, .column = 8 }, .first = 5, .count = 1, .text_start = 5, .text_length = 0, .number = 0 }, - .{ .kind = .call, .op = 0, .at = .{ .line = 1, .column = 0 }, .first = 6, .count = 3, .text_start = 5, .text_length = 0, .number = 0 }, - .{ .kind = .call_statement, .op = 0, .at = .{ .line = 1, .column = 0 }, .first = 9, .count = 1, .text_start = 5, .text_length = 0, .number = 0 }, - .{ .kind = .block, .op = 0, .at = .{ .line = 0, .column = 0 }, .first = 10, .count = 2, .text_start = 5, .text_length = 0, .number = 0 }, + .{ .kind = .global_name, .op = 0, .at = .{ .line = 0, .column = 0 }, .end = .{ .line = 0, .column = 1 }, .first = 0, .count = 0, .text_start = 0, .text_length = 1, .number = 0 }, + .{ .kind = .number, .op = 0, .at = .{ .line = 0, .column = 6 }, .end = .{ .line = 0, .column = 7 }, .first = 0, .count = 0, .text_start = 1, .text_length = 0, .number = 2 }, + .{ .kind = .unary, .op = @backingInt(UnaryOperator.minus), .at = .{ .line = 0, .column = 5 }, .end = .{ .line = 0, .column = 7 }, .first = 0, .count = 1, .text_start = 1, .text_length = 0, .number = 0 }, + .{ .kind = .compound_assign, .op = @backingInt(BinaryOperator.add), .at = .{ .line = 0, .column = 0 }, .end = .{ .line = 0, .column = 7 }, .first = 1, .count = 2, .text_start = 1, .text_length = 0, .number = 0 }, + .{ .kind = .global_name, .op = 0, .at = .{ .line = 1, .column = 0 }, .end = .{ .line = 1, .column = 2 }, .first = 3, .count = 0, .text_start = 1, .text_length = 2, .number = 0 }, + .{ .kind = .string, .op = 0, .at = .{ .line = 1, .column = 3 }, .end = .{ .line = 1, .column = 6 }, .first = 3, .count = 0, .text_start = 3, .text_length = 1, .number = 0 }, + .{ .kind = .string, .op = 0, .at = .{ .line = 1, .column = 10 }, .end = .{ .line = 1, .column = 11 }, .first = 3, .count = 0, .text_start = 4, .text_length = 1, .number = 0 }, + .{ .kind = .boolean, .op = 1, .at = .{ .line = 1, .column = 14 }, .end = .{ .line = 1, .column = 18 }, .first = 3, .count = 0, .text_start = 5, .text_length = 0, .number = 0 }, + .{ .kind = .item, .op = @backingInt(ItemKind.record), .at = .{ .line = 1, .column = 10 }, .end = .{ .line = 1, .column = 18 }, .first = 3, .count = 2, .text_start = 5, .text_length = 0, .number = 0 }, + .{ .kind = .table, .op = 0, .at = .{ .line = 1, .column = 8 }, .end = .{ .line = 1, .column = 20 }, .first = 5, .count = 1, .text_start = 5, .text_length = 0, .number = 0 }, + .{ .kind = .call, .op = 0, .at = .{ .line = 1, .column = 0 }, .end = .{ .line = 1, .column = 21 }, .first = 6, .count = 3, .text_start = 5, .text_length = 0, .number = 0 }, + .{ .kind = .call_statement, .op = 0, .at = .{ .line = 1, .column = 0 }, .end = .{ .line = 1, .column = 21 }, .first = 9, .count = 1, .text_start = 5, .text_length = 0, .number = 0 }, + .{ .kind = .block, .op = 0, .at = .{ .line = 0, .column = 0 }, .end = .{ .line = 1, .column = 21 }, .first = 10, .count = 2, .text_start = 5, .text_length = 0, .number = 0 }, }; const children = [_]Index{ 1, 0, 2, 6, 7, 8, 4, 5, 9, 10, 3, 11 }; const tree: Tree = .{ .nodes = &nodes, .children = &children, .text = "xonab", .errors = &.{}, .root = 12 }; @@ -352,8 +360,9 @@ test Tree { const item = tree.get(8).item.record; try std.testing.expectEqualStrings("b", item.key); try std.testing.expect(tree.get(item.value).boolean); - // An item starts at its key. + // An item starts at its key, and ends with its value. try std.testing.expectEqual(Position{ .line = 1, .column = 10 }, tree.at(8)); + try std.testing.expectEqual(Position{ .line = 1, .column = 18 }, tree.end(8)); var out: std.Io.Writer.Allocating = .init(std.testing.allocator); defer out.deinit(); diff --git a/src/formats/dte/source/write.zig b/src/formats/dte/source/write.zig index bbd7c2b8..32a480bf 100644 --- a/src/formats/dte/source/write.zig +++ b/src/formats/dte/source/write.zig @@ -25,7 +25,7 @@ pub fn write(writer: *Writer, arena: Allocator, mission: dte.Mission) Error!void } /// The longest line a declaration's fields keep to before they go on lines of their own. -const line_length = 100; +pub const line_length = 100; const Source = struct { arena: Allocator, @@ -457,7 +457,7 @@ fn writeList(w: *Writer, items: []const []const u8, column: usize) Writer.Error! /// `items` as a table, `{ a, b }`, starting `column` characters into its line, with `after` /// characters after it on the line: on that line where it fits, or with an item to a line. -fn writeTable(w: *Writer, items: []const []const u8, column: usize, after: usize) Writer.Error!void { +pub fn writeTable(w: *Writer, items: []const []const u8, column: usize, after: usize) Writer.Error!void { if (items.len == 0) return w.writeAll("{}"); var length = column + "{ }".len + after; for (items, 0..) |item, index| length += item.len + @as(usize, if (index > 0) ", ".len else 0); @@ -488,7 +488,7 @@ fn named(kind: vocabulary.Kind, value: u32) Named { /// A number as Luau writes it: the shortest that reads back as the same `f32`, with an exponent /// from `exponent_from` on, and `0/0`, `1/0` and `-1/0` for those Luau has no literal for. -const Number = struct { +pub const Number = struct { value: f32, /// The smallest number written with an exponent, 2^53. From there on a double holds only some @@ -503,12 +503,12 @@ const Number = struct { } }; -fn number(value: f32) Number { +pub fn number(value: f32) Number { return .{ .value = value }; } /// A place as `{ x, y, z }`. -const Vector = struct { +pub const Vector = struct { value: [3]f32, pub fn format(shown: Vector, w: *Writer) Writer.Error!void { @@ -516,7 +516,7 @@ const Vector = struct { } }; -fn vector(value: [3]f32) Vector { +pub fn vector(value: [3]f32) Vector { return .{ .value = value }; } diff --git a/src/source_parser.zig b/src/source_parser.zig index 3edb8a58..97b62578 100644 --- a/src/source_parser.zig +++ b/src/source_parser.zig @@ -79,6 +79,8 @@ comptime { } assert(@offsetOf(syntax.Node, "at") + @offsetOf(syntax.Position, "line") == @offsetOf(luau.OrNode, "line")); assert(@offsetOf(syntax.Node, "at") + @offsetOf(syntax.Position, "column") == @offsetOf(luau.OrNode, "column")); + assert(@offsetOf(syntax.Node, "end") + @offsetOf(syntax.Position, "line") == @offsetOf(luau.OrNode, "end_line")); + assert(@offsetOf(syntax.Node, "end") + @offsetOf(syntax.Position, "column") == @offsetOf(luau.OrNode, "end_column")); sameNumbers(syntax.Kind, "OR_", &.{ "OR_UNARY_", "OR_BINARY_", "OR_ITEM_" }); sameNumbers(syntax.UnaryOperator, "OR_UNARY_", &.{}); sameNumbers(syntax.BinaryOperator, "OR_BINARY_", &.{}); @@ -599,6 +601,101 @@ fn statementOn(compiled: compile.Compiled, line: u32) ?compile.Compiled.Statemen return null; } +test "a node ends where Luau's parser ends it" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const tree = try parse(arena, "sabre = ship(\"Sabre\", { type = \"sabre\" })\n"); + const assign = tree.get(tree.get(tree.root).block[0]).assign; + const call = assign.values[0]; + try std.testing.expectEqual(syntax.Position{ .line = 0, .column = 8 }, tree.at(call)); + try std.testing.expectEqual(syntax.Position{ .line = 0, .column = 41 }, tree.end(call)); + // A table's item runs from its key to the end of its value. + const table = tree.get(call).call.arguments[1]; + const item = tree.get(table).table[0]; + try std.testing.expectEqual(syntax.Position{ .line = 0, .column = 24 }, tree.at(item)); + try std.testing.expectEqual(syntax.Position{ .line = 0, .column = 38 }, tree.end(item)); +} + +test "edits to a declaration keep the rest of the source" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const text = + \\alpha = flight_group("(FG)Alpha", { wing = "player" }) + \\nav = nav_point("Nav 1") + \\sabre = ship("Sabre", { + \\ type = "sabre", + \\ group = alpha, -- the enemies + \\ position = { 0, 0, 40000 }, + \\}) + \\on(nav, "ship_reached", function() + \\ return 1 + \\end) + \\ + ; + const edit = dte.source.edit; + const source: edit.Source = try .init(arena, text, try parse(arena, text)); + const Case = struct { edit: ?edit.Edit, wanted: []const u8 }; + const sabre = source.declaration("sabre").?; + const alpha = source.declaration("alpha").?; + const nav = source.declaration("nav").?; + const trigger = source.callOn(7).?; + for ([_]Case{ + // A field's value is replaced. + .{ .edit = try source.setField(arena, sabre, 1, "position", .{ .vector = .{ -2000, 0, 42000.5 } }), .wanted = " position = { -2000, 0, 42000.5 },\n" }, + // A new field goes on a new line, with the same indent as the others. + .{ .edit = try source.setField(arena, sabre, 1, "yaw", .{ .number = 180 }), .wanted = " position = { 0, 0, 40000 },\n yaw = 180,\n})" }, + // In a one-line table, it goes after the last item. + .{ .edit = try source.setField(arena, alpha, 1, "formation", .{ .name = "vee" }), .wanted = "{ wing = \"player\", formation = vee })" }, + // A call without a table gets one. + .{ .edit = try source.setField(arena, nav, 1, "position", .{ .vector = .{ 0, 0, 40000 } }), .wanted = "nav_point(\"Nav 1\", { position = { 0, 0, 40000 } })" }, + // A trigger without fields gets a table before its function. + .{ .edit = try source.setField(arena, trigger, 2, "repeats", .{ .text = "always" }), .wanted = "on(nav, \"ship_reached\", { repeats = \"always\" }, function()" }, + // Removing a field removes its line, comment included. + .{ .edit = source.removeField(sabre, 1, "group"), .wanted = " type = \"sabre\",\n position" }, + // Removing the only field leaves an empty table. + .{ .edit = source.removeField(alpha, 1, "wing"), .wanted = "flight_group(\"(FG)Alpha\", {})" }, + // An argument such as the display name, with the string escaped. + .{ .edit = try source.setArgument(arena, sabre, 0, .{ .text = "Sabre \"1\"" }), .wanted = "ship(\"Sabre \\\"1\\\"\", {" }, + // A declaration goes after the last one with the same word. + .{ .edit = try source.declare(arena, source.lastDeclarationOf("ship"), "sabre_2", "ship", &.{ .{ .text = "Sabre 2" }, .{ .table = &.{.{ .key = "type", .value = .{ .text = "sabre" } }} } }), .wanted = "})\nsabre_2 = ship(\"Sabre 2\", { type = \"sabre\" })\non(nav" }, + }) |case| try expectEdit(arena, source, case.edit.?, case.wanted); + // A field the table doesn't have can't be removed, and a table can't go past the arguments. + try std.testing.expectEqual(null, source.removeField(sabre, 1, "pilot")); + try std.testing.expectEqual(null, try source.setField(arena, nav, 3, "yaw", .{ .number = 1 })); +} + +test "edits follow an unusual layout" { + var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + // The table ends on its last item's line, and the text's last line has no line break. + const text = + \\sabre = ship("Sabre", { + \\ type = "sabre" }) + ; + const edit = dte.source.edit; + const source: edit.Source = try .init(arena, text, try parse(arena, text)); + const sabre = source.declaration("sabre").?; + // A new field goes on the last item's line, before the table's end. + try expectEdit(arena, source, (try source.setField(arena, sabre, 1, "yaw", .{ .number = 90 })).?, " type = \"sabre\", yaw = 90 })"); + // A declaration after the last line starts a new line. + const nav = try source.declare(arena, source.lastDeclarationOf("ship"), "nav", "nav_point", &.{.{ .text = "Nav 1" }}); + try expectEdit(arena, source, nav, "\"sabre\" })\nnav = nav_point(\"Nav 1\")\n"); +} + +/// Checks that the text after `edit` contains `wanted`, and that Luau's parser reads it without +/// errors. +fn expectEdit(arena: Allocator, source: dte.source.edit.Source, edit: dte.source.edit.Edit, wanted: []const u8) !void { + const made = try source.apply(arena, edit); + if (std.mem.find(u8, made, wanted) == null) { + std.debug.print("{s}\nis not in:\n{s}\n", .{ wanted, made }); + return error.TestUnexpectedResult; + } + try std.testing.expectEqual(0, (try parse(arena, made)).errors.len); +} + test "the guide's mistake is reported as the guide shows it" { var arena_state: std.heap.ArenaAllocator = .init(std.testing.allocator); defer arena_state.deinit(); diff --git a/src/source_parser/luau.cpp b/src/source_parser/luau.cpp index db177cdd..527013f4 100644 --- a/src/source_parser/luau.cpp +++ b/src/source_parser/luau.cpp @@ -57,6 +57,8 @@ struct Builder { made.op = op; made.line = location.begin.line; made.column = location.begin.column; + made.end_line = location.end.line; + made.end_column = location.end.column; made.first = static_cast(children.size()); made.count = static_cast(kids.size()); children.insert(children.end(), kids.begin(), kids.end()); @@ -192,9 +194,9 @@ struct Builder { std::vector parts; if (item.key) parts.push_back(expression(item.key)); parts.push_back(expression(item.value)); - // An item starts at its key, where it has one. - kids.push_back(node(OR_ITEM, (item.key ? item.key : item.value)->location, parts, - static_cast(item.kind))); + // An item starts at its key, where it has one, and ends with its value. + const Luau::Location span((item.key ? item.key : item.value)->location.begin, item.value->location.end); + kids.push_back(node(OR_ITEM, span, parts, static_cast(item.kind))); } return node(OR_TABLE, at, kids); } diff --git a/src/source_parser/luau.h b/src/source_parser/luau.h index b2423093..1f6f91f5 100644 --- a/src/source_parser/luau.h +++ b/src/source_parser/luau.h @@ -69,7 +69,8 @@ enum { OR_ITEM_GENERAL = 2, }; -/* A node. Its kind decides what `op` holds (`syntax.Node.op`). `first` and `count` give its +/* A node. Its kind decides what `op` holds (`syntax.Node.op`). `line` and `column` give where it + * starts, and `end_line` and `end_column` where it ends, just past it. `first` and `count` give its * children's indices in `OrTree.children`, and `text_start` and `text_length` its text in * `OrTree.text`. `number` holds a number's value, and for a name and a local's use the local's * number, which each local the file declares has of its own. */ @@ -78,6 +79,8 @@ typedef struct { uint8_t op; uint32_t line; uint32_t column; + uint32_t end_line; + uint32_t end_column; uint32_t first; uint32_t count; uint32_t text_start;