gdharness 1.2.25 → 1.2.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,7 @@ extends RefCounted
2
2
 
3
3
  const Read = preload("reading.gd")
4
4
  const Log = preload("logger.gd")
5
+ const ProjectFile = preload("project_file.gd")
5
6
  const Serialisation = preload("serialisation.gd")
6
7
 
7
8
  var _log: Log
@@ -124,12 +125,7 @@ func set_project_setting(params: Dictionary) -> Dictionary:
124
125
  )
125
126
  final_value = type_convert(final_value, wanted)
126
127
 
127
- ProjectSettings.set_setting(setting_path, final_value)
128
- var err: Error = ProjectSettings.save()
129
- if err != OK:
130
- return _log.failure("Failed to save project.godot: " + error_string(err))
131
-
132
- return {
128
+ var answer: Dictionary = {
133
129
  "setting_path": setting_path,
134
130
  "old_value": _values.serialize_value(old_value) if had_value else null,
135
131
  "new_value": _values.serialize_value(final_value),
@@ -137,6 +133,18 @@ func set_project_setting(params: Dictionary) -> Dictionary:
137
133
  "was_default": was_default,
138
134
  "saved": true,
139
135
  }
136
+ # The same value in the same type is no change, and the file is left alone: a save rewrites it
137
+ # whole. The type is compared as well, so an int setting the file holds as 50.0 is still put right.
138
+ if had_value and typeof(old_value) == typeof(final_value) and old_value == final_value:
139
+ answer["saved"] = false
140
+ answer["unchanged"] = true
141
+ return answer
142
+
143
+ ProjectSettings.set_setting(setting_path, final_value)
144
+ var err: Error = ProjectFile.save()
145
+ if err != OK:
146
+ return _log.failure("Failed to save project.godot: " + error_string(err))
147
+ return answer
140
148
 
141
149
 
142
150
  # The type a setting wants, or TYPE_NIL where the engine names none and there is nothing to go on.
@@ -201,7 +209,7 @@ func add_autoload(params: Dictionary) -> Dictionary:
201
209
  # "Global Variable" column. Without it the node still comes up under the root; only the name
202
210
  # stops resolving.
203
211
  ProjectSettings.set_setting(setting, ("*" if global else "") + path)
204
- var err: Error = ProjectSettings.save()
212
+ var err: Error = ProjectFile.save()
205
213
  if err != OK:
206
214
  return _log.failure("Failed to save project.godot: " + error_string(err))
207
215
 
@@ -226,7 +234,7 @@ func remove_autoload(params: Dictionary) -> Dictionary:
226
234
 
227
235
  var old_value: String = str(ProjectSettings.get_setting(setting))
228
236
  ProjectSettings.set_setting(setting, null)
229
- var err: Error = ProjectSettings.save()
237
+ var err: Error = ProjectFile.save()
230
238
  if err != OK:
231
239
  return _log.failure("Failed to save project.godot: " + error_string(err))
232
240
 
@@ -275,7 +283,7 @@ func set_main_scene(params: Dictionary) -> Dictionary:
275
283
  var old_main_scene: String = str(ProjectSettings.get_setting("application/run/main_scene", ""))
276
284
 
277
285
  ProjectSettings.set_setting("application/run/main_scene", scene_path)
278
- var err: Error = ProjectSettings.save()
286
+ var err: Error = ProjectFile.save()
279
287
 
280
288
  if err != OK:
281
289
  return _log.failure("Failed to save project settings: " + str(err))
@@ -0,0 +1,202 @@
1
+ extends RefCounted
2
+
3
+ ## project.godot written through the engine's own save, in the order the file already held.
4
+ ##
5
+ ## The engine writes each section's keys in the order it registered them, and an engine running a
6
+ ## script registers them in another order than the editor does, so a one-setting change moved lines
7
+ ## nobody touched across several sections and a commit of it read as a rewrite. The save is still the
8
+ ## engine's, for what only it gets right: the header, each value's text and dropping a key that is
9
+ ## back at its default. Only the order is taken from the file as it was.
10
+
11
+ const PATH: String = "res://project.godot"
12
+
13
+
14
+ ## Saves the project's settings, keeping the sections and keys already in the file where they were.
15
+ ## A key or a section new to the file goes where the engine put it relative to its neighbours.
16
+ static func save() -> Error:
17
+ var before: String = FileAccess.get_file_as_string(PATH)
18
+ var err: Error = ProjectSettings.save()
19
+ if err != OK or before.is_empty():
20
+ return err
21
+ var written: String = FileAccess.get_file_as_string(PATH)
22
+ var kept: String = in_order_of(before, written)
23
+ if kept == written:
24
+ return OK
25
+ var file: FileAccess = FileAccess.open(PATH, FileAccess.WRITE)
26
+ if file == null:
27
+ return FileAccess.get_open_error()
28
+ if not file.store_string(kept):
29
+ return file.get_error()
30
+ file.close()
31
+ return OK
32
+
33
+
34
+ ## [param written] with its sections and their keys put in the order [param before] has them. Every
35
+ ## line of the result is a line of [param written], so nothing the engine wrote is lost or changed.
36
+ static func in_order_of(before: String, written: String) -> String:
37
+ var old: Dictionary = _parsed(before)
38
+ var fresh: Dictionary = _parsed(written)
39
+ var old_sections: Array[Dictionary] = old["sections"]
40
+ var fresh_sections: Array[Dictionary] = fresh["sections"]
41
+
42
+ var old_names: Array[String] = []
43
+ var old_keys: Dictionary[String, Array] = {}
44
+ for section: Dictionary in old_sections:
45
+ var name: String = section["name"]
46
+ old_names.append(name)
47
+ var keys: Array[String] = []
48
+ for entry: Dictionary in section["entries"]:
49
+ keys.append(str(entry["key"]))
50
+ old_keys[name] = keys
51
+
52
+ var by_name: Dictionary[String, Dictionary] = {}
53
+ var fresh_names: Array[String] = []
54
+ for section: Dictionary in fresh_sections:
55
+ var name: String = section["name"]
56
+ by_name[name] = section
57
+ fresh_names.append(name)
58
+
59
+ var text: String = fresh["preamble"]
60
+ var at: int = 0
61
+ for name: String in _merged(old_names, fresh_names):
62
+ var section: Dictionary = by_name[name]
63
+ var entries: Array[Dictionary] = section["entries"]
64
+ var entry_of: Dictionary[String, String] = {}
65
+ var fresh_keys: Array[String] = []
66
+ for entry: Dictionary in entries:
67
+ var key: String = entry["key"]
68
+ entry_of[key] = str(entry["text"])
69
+ fresh_keys.append(key)
70
+ var known: Array = old_keys.get(name, [])
71
+ var held: Array[String] = []
72
+ held.assign(known)
73
+ text += str(section["head"])
74
+ for key: String in _merged(held, fresh_keys):
75
+ text += entry_of[key]
76
+ # The blank lines closing a section belong to its place in the file rather than to the
77
+ # section, so the last one keeps the ending the engine gave the file wherever it moved from.
78
+ var placed: Dictionary = fresh_sections[at]
79
+ text += str(placed["tail"])
80
+ at += 1
81
+ return text
82
+
83
+
84
+ ## The names of [param wanted] in the order [param held] lists them, those [param held] lacks each
85
+ ## placed after the name [param wanted] puts before it. Names [param wanted] lacks are left out.
86
+ static func _merged(held: Array[String], wanted: Array[String]) -> Array[String]:
87
+ var order: Array[String] = []
88
+ for name: String in held:
89
+ if name in wanted and not name in order:
90
+ order.append(name)
91
+ var after: int = -1
92
+ for name: String in wanted:
93
+ var placed: int = order.find(name)
94
+ if placed == -1:
95
+ after += 1
96
+ # Not checked: a position inside the array cannot be refused.
97
+ var _inserted: int = order.insert(after, name)
98
+ else:
99
+ after = placed
100
+ return order
101
+
102
+
103
+ ## [param text] as a preamble (the header and anything before the first section) and its sections,
104
+ ## each with its header line and the blank lines after it, its entries, and the blank lines closing it.
105
+ static func _parsed(text: String) -> Dictionary:
106
+ var preamble: String = ""
107
+ var sections: Array[Dictionary] = []
108
+ var current: Dictionary = {}
109
+ var body: Array[String] = []
110
+ for line: String in _logical_lines(text):
111
+ var stripped: String = line.strip_edges()
112
+ if stripped.begins_with("[") and stripped.ends_with("]"):
113
+ if not current.is_empty():
114
+ _close(current, body)
115
+ sections.append(current)
116
+ current = {"name": stripped.substr(1, stripped.length() - 2), "head": line}
117
+ body = []
118
+ elif current.is_empty():
119
+ preamble += line
120
+ elif body.is_empty() and stripped.is_empty():
121
+ current["head"] = str(current["head"]) + line
122
+ else:
123
+ body.append(line)
124
+ if not current.is_empty():
125
+ _close(current, body)
126
+ sections.append(current)
127
+ return {"preamble": preamble, "sections": sections}
128
+
129
+
130
+ ## Fills in [param section]'s entries and closing blank lines from the lines after its head. A blank
131
+ ## or comment line between two entries stays with the entry before it.
132
+ static func _close(section: Dictionary, body: Array[String]) -> void:
133
+ var tail: String = ""
134
+ while not body.is_empty() and body[body.size() - 1].strip_edges().is_empty():
135
+ tail = body[body.size() - 1] + tail
136
+ body.remove_at(body.size() - 1)
137
+ var entries: Array[Dictionary] = []
138
+ for line: String in body:
139
+ var stripped: String = line.strip_edges()
140
+ if entries.is_empty() or not (stripped.is_empty() or stripped.begins_with(";")):
141
+ entries.append({"key": _key_of(line), "text": line})
142
+ else:
143
+ var last: Dictionary = entries[entries.size() - 1]
144
+ last["text"] = str(last["text"]) + line
145
+ section["entries"] = entries
146
+ section["tail"] = tail
147
+
148
+
149
+ ## The key an entry sets: everything before its first `=` outside quotes, since a key with spaces or
150
+ ## symbols in it is written quoted.
151
+ static func _key_of(line: String) -> String:
152
+ var quoted: bool = false
153
+ for i: int in line.length():
154
+ var c: String = line[i]
155
+ if c == '"' and (i == 0 or line[i - 1] != "\\"):
156
+ quoted = not quoted
157
+ elif c == "=" and not quoted:
158
+ return line.substr(0, i).strip_edges()
159
+ return line.strip_edges()
160
+
161
+
162
+ ## [param text] split into lines that each end where an entry does: a value holding a dictionary, an
163
+ ## array or a string runs over several lines, and splitting it there would sort its halves apart.
164
+ ## Each piece keeps its line ending, so joining them gives the text back.
165
+ static func _logical_lines(text: String) -> Array[String]:
166
+ var lines: Array[String] = []
167
+ var start: int = 0
168
+ var depth: int = 0
169
+ var quoted: bool = false
170
+ var escaped: bool = false
171
+ var comment: bool = false
172
+ for i: int in text.length():
173
+ var c: String = text[i]
174
+ if i == start:
175
+ comment = c == ";"
176
+ if comment:
177
+ if c == "\n":
178
+ lines.append(text.substr(start, i + 1 - start))
179
+ start = i + 1
180
+ continue
181
+ if quoted:
182
+ if escaped:
183
+ escaped = false
184
+ elif c == "\\":
185
+ escaped = true
186
+ elif c == '"':
187
+ quoted = false
188
+ continue
189
+ match c:
190
+ '"':
191
+ quoted = true
192
+ "[", "(", "{":
193
+ depth += 1
194
+ "]", ")", "}":
195
+ depth = maxi(depth - 1, 0)
196
+ "\n":
197
+ if depth == 0:
198
+ lines.append(text.substr(start, i + 1 - start))
199
+ start = i + 1
200
+ if start < text.length():
201
+ lines.append(text.substr(start))
202
+ return lines