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;