439 lines
11 KiB
Python
439 lines
11 KiB
Python
# Copyright 2004-2019 Tom Rothamel <pytom@bishoujo.us>
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person
|
|
# obtaining a copy of this software and associated documentation files
|
|
# (the "Software"), to deal in the Software without restriction,
|
|
# including without limitation the rights to use, copy, modify, merge,
|
|
# publish, distribute, sublicense, and/or sell copies of the Software,
|
|
# and to permit persons to whom the Software is furnished to do so,
|
|
# subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be
|
|
# included in all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
|
|
# This file contains code to add and remove statements from the AST
|
|
# and the textual representation of Ren'Py code.
|
|
|
|
from __future__ import print_function
|
|
|
|
import renpy
|
|
import re
|
|
import codecs
|
|
|
|
|
|
# A map from line loc (elided filename, line) to the Line object representing
|
|
# that line.
|
|
lines = { }
|
|
|
|
# The set of files that have been loaded.
|
|
files = set()
|
|
|
|
|
|
class Line(object):
|
|
"""
|
|
Represents a logical line in a file.
|
|
"""
|
|
|
|
def __init__(self, filename, number, start):
|
|
|
|
filename = filename.replace("\\", "/")
|
|
|
|
# The full path to the file with the line in it.
|
|
self.filename = filename
|
|
|
|
# The line number.
|
|
self.number = number
|
|
|
|
# The offset inside the file at which the line starts.
|
|
self.start = start
|
|
|
|
# The offset inside the file at which the line ends.
|
|
self.end = start
|
|
|
|
# The offset inside the lime where the line delimiter ends.
|
|
self.end_delim = start
|
|
|
|
# The text of the line.
|
|
self.text = ''
|
|
|
|
def __repr__(self):
|
|
return "<Line {}:{} {!r}>".format(self.filename, self.number, self.text)
|
|
|
|
|
|
def ensure_loaded(filename):
|
|
"""
|
|
Ensures that the given filename and linenumber are loaded. Doesn't do
|
|
anything if the filename can't be loaded.
|
|
"""
|
|
|
|
if not (filename.endswith(".rpy") or filename.endswith(".rpyc")):
|
|
return
|
|
|
|
if filename in files:
|
|
return
|
|
|
|
files.add(filename)
|
|
|
|
fn = renpy.parser.unelide_filename(filename)
|
|
renpy.parser.list_logical_lines(fn, add_lines=True)
|
|
|
|
|
|
def get_line_text(filename, linenumber):
|
|
"""
|
|
Gets the text of the line with `filename` and `linenumber`, or the None if
|
|
the line does not exist.
|
|
"""
|
|
|
|
filename = filename.replace("\\", "/")
|
|
|
|
ensure_loaded(filename)
|
|
|
|
if (filename, linenumber) in lines:
|
|
return lines[filename, linenumber].text
|
|
else:
|
|
return None
|
|
|
|
|
|
def adjust_line_locations(filename, linenumber, char_offset, line_offset):
|
|
"""
|
|
Adjusts the locations in the line data structure.
|
|
|
|
`filename`, `linenumber`
|
|
The filename and first line number to adjust.
|
|
|
|
`char_offset`
|
|
The number of characters in the file to offset the code by,.
|
|
|
|
`line_offset`
|
|
The number of line in the file to offset the code by.
|
|
"""
|
|
|
|
filename = filename.replace("\\", "/")
|
|
|
|
ensure_loaded(filename)
|
|
|
|
global lines
|
|
|
|
new_lines = { }
|
|
|
|
for key, line in lines.iteritems():
|
|
|
|
(fn, ln) = key
|
|
|
|
if (fn == filename) and (linenumber <= ln):
|
|
ln += line_offset
|
|
line.number += line_offset
|
|
line.start += char_offset
|
|
line.end += char_offset
|
|
line.end_delim += char_offset
|
|
|
|
new_lines[fn, ln] = line
|
|
|
|
lines = new_lines
|
|
|
|
|
|
def insert_line_before(code, filename, linenumber):
|
|
"""
|
|
Adds `code` immediately before `filename` and `linenumber`. Those must
|
|
correspond to an existing line, and the code is inserted with the same
|
|
indentation as that line.
|
|
"""
|
|
|
|
filename = filename.replace("\\", "/")
|
|
|
|
if renpy.config.clear_lines:
|
|
raise Exception("config.clear_lines must be False for script editing to work.")
|
|
|
|
ensure_loaded(filename)
|
|
|
|
old_line = lines[filename, linenumber]
|
|
|
|
m = re.match(r' *', old_line.text)
|
|
indent = m.group(0)
|
|
|
|
if not code:
|
|
indent = ''
|
|
|
|
if old_line.text.endswith("\r\n") or not old_line.text.endswith("\n"):
|
|
line_ending = "\r\n"
|
|
else:
|
|
line_ending = "\n"
|
|
|
|
raw_code = indent + code
|
|
code = indent + code + line_ending
|
|
|
|
new_line = Line(old_line.filename, old_line.number, old_line.start)
|
|
new_line.text = raw_code
|
|
new_line.full_text = code
|
|
new_line.end = new_line.start + len(raw_code)
|
|
new_line.end_delim = new_line.start + len(code)
|
|
|
|
with codecs.open(old_line.filename, "r", "utf-8") as f:
|
|
data = f.read()
|
|
|
|
data = data[:old_line.start] + code + data[old_line.start:]
|
|
|
|
adjust_line_locations(filename, linenumber, len(code), code.count("\n"))
|
|
|
|
with renpy.loader.auto_lock:
|
|
|
|
with codecs.open(old_line.filename, "w", "utf-8") as f:
|
|
f.write(data)
|
|
|
|
renpy.loader.add_auto(old_line.filename, force=True)
|
|
|
|
lines[filename, linenumber] = new_line
|
|
|
|
|
|
def remove_line(filename, linenumber):
|
|
"""
|
|
Removes `linenumber` from `filename`. The line must exist and correspond
|
|
to a logical line.
|
|
"""
|
|
|
|
filename = filename.replace("\\", "/")
|
|
|
|
if renpy.config.clear_lines:
|
|
raise Exception("config.clear_lines must be False for script editing to work.")
|
|
|
|
ensure_loaded(filename)
|
|
|
|
line = lines[filename, linenumber]
|
|
|
|
with codecs.open(line.filename, "r", "utf-8") as f:
|
|
data = f.read()
|
|
|
|
code = data[line.start:line.end_delim]
|
|
data = data[:line.start] + data[line.end_delim:]
|
|
|
|
del lines[filename, linenumber]
|
|
adjust_line_locations(filename, linenumber, -len(code), -code.count("\n"))
|
|
|
|
with renpy.loader.auto_lock:
|
|
|
|
with codecs.open(line.filename, "w", "utf-8") as f:
|
|
f.write(data)
|
|
|
|
renpy.loader.add_auto(line.filename, force=True)
|
|
|
|
|
|
def get_full_text(filename, linenumber):
|
|
"""
|
|
Returns the full text of `linenumber` from `filename`, including
|
|
any comment or delimiter characters that exist.
|
|
"""
|
|
|
|
filename = filename.replace("\\", "/")
|
|
|
|
ensure_loaded(filename)
|
|
|
|
if (filename, linenumber) not in lines:
|
|
return None
|
|
|
|
return lines[filename, linenumber].full_text
|
|
|
|
|
|
def nodes_on_line(filename, linenumber):
|
|
"""
|
|
Returns a list of nodes that are found on the given line.
|
|
"""
|
|
|
|
ensure_loaded(filename)
|
|
|
|
rv = [ ]
|
|
|
|
for i in renpy.game.script.all_stmts:
|
|
if (i.filename == filename) and (i.linenumber == linenumber):
|
|
rv.append(i)
|
|
|
|
return rv
|
|
|
|
|
|
def nodes_on_line_at_or_after(filename, linenumber):
|
|
"""
|
|
Returns a list of nodes that are found at or after the given line.
|
|
"""
|
|
|
|
ensure_loaded(filename)
|
|
|
|
lines = [ i.linenumber
|
|
for i in renpy.game.script.all_stmts
|
|
if (i.filename == filename)
|
|
if (i.linenumber >= linenumber) ]
|
|
|
|
if not lines:
|
|
return [ ]
|
|
|
|
return nodes_on_line(filename, min(lines))
|
|
|
|
|
|
def first_and_last_nodes(nodes):
|
|
"""
|
|
Finds the first and last nodes in `nodes`, a list of nodes. This assumes
|
|
that all the nodes are "simple", with no control flow, and that all of
|
|
the relevant nodes are in `nodes`.
|
|
"""
|
|
|
|
firsts = [ ]
|
|
lasts = [ ]
|
|
|
|
for i in nodes:
|
|
for j in nodes:
|
|
if j.next is i:
|
|
break
|
|
else:
|
|
firsts.append(i)
|
|
|
|
for j in nodes:
|
|
if i.next is j:
|
|
break
|
|
|
|
else:
|
|
lasts.append(i)
|
|
|
|
if len(firsts) != 1:
|
|
raise Exception("Could not find unique first AST node.")
|
|
|
|
if len(lasts) != 1:
|
|
raise Exception("Could not find unique last AST node.")
|
|
|
|
return firsts[0], lasts[0]
|
|
|
|
|
|
def adjust_ast_linenumbers(filename, linenumber, offset):
|
|
"""
|
|
This adjusts the line numbers in the the ast.
|
|
|
|
`filename`
|
|
The filename to adjust.
|
|
|
|
`linenumber`
|
|
The first line to adjust.
|
|
|
|
`offset`
|
|
The amount to adjust by. Positive numbers increase the line
|
|
"""
|
|
|
|
for i in renpy.game.script.all_stmts:
|
|
if (i.filename == filename) and (i.linenumber >= linenumber):
|
|
i.linenumber += offset
|
|
|
|
|
|
def add_to_ast_before(code, filename, linenumber):
|
|
"""
|
|
Adds `code`, which must be a textual line of Ren'Py code,
|
|
before the given filename and line number.
|
|
"""
|
|
|
|
nodes = nodes_on_line_at_or_after(filename, linenumber)
|
|
old, _ = first_and_last_nodes(nodes)
|
|
|
|
adjust_ast_linenumbers(old.filename, linenumber, 1)
|
|
|
|
block, _init = renpy.game.script.load_string(old.filename, code, linenumber=linenumber)
|
|
|
|
# Remove the return statement at the end of the block.
|
|
ret_stmt = block.pop()
|
|
renpy.game.script.all_stmts.remove(ret_stmt)
|
|
|
|
if not block:
|
|
return
|
|
|
|
for i in renpy.game.script.all_stmts:
|
|
i.replace_next(old, block[0])
|
|
|
|
renpy.ast.chain_block(block, old)
|
|
|
|
for i in renpy.game.contexts:
|
|
i.replace_node(old, block[0])
|
|
|
|
renpy.game.log.replace_node(old, block[0])
|
|
|
|
|
|
def can_add_before(filename, linenumber):
|
|
"""
|
|
Returns True if it's possible to add a line before the given filename
|
|
and linenumber, and False if it's not possible.
|
|
"""
|
|
|
|
try:
|
|
nodes = nodes_on_line(filename, linenumber)
|
|
first_and_last_nodes(nodes)
|
|
|
|
return True
|
|
except:
|
|
return False
|
|
|
|
|
|
def remove_from_ast(filename, linenumber):
|
|
"""
|
|
Removes from the AST all statements that happen to be at `filename`
|
|
and `linenumber`, then adjusts the line numbers appropriately.
|
|
|
|
There's an assumption that the statement(s) on the line are "simple",
|
|
not involving control flow.
|
|
"""
|
|
|
|
nodes = nodes_on_line(filename, linenumber)
|
|
|
|
first, last = first_and_last_nodes(nodes)
|
|
|
|
new_stmts = [ ]
|
|
|
|
for i in renpy.game.script.all_stmts:
|
|
if i in nodes:
|
|
continue
|
|
|
|
i.replace_next(first, last.next)
|
|
|
|
new_stmts.append(i)
|
|
|
|
renpy.game.script.all_stmts = new_stmts
|
|
|
|
namemap = renpy.game.script.namemap
|
|
|
|
# This is fairly slow - when we remove a node, we have to replace it with
|
|
# the next node. But if we then remove the next node, we have to replace it
|
|
# again. So we just walk all the known names to do this.
|
|
for k in list(namemap):
|
|
if namemap[k] in nodes:
|
|
namemap[k] = last.next
|
|
|
|
adjust_ast_linenumbers(filename, linenumber, -1)
|
|
|
|
|
|
serial = 1
|
|
|
|
|
|
def test_add():
|
|
|
|
global serial
|
|
s = "'Hello world %f'" % serial
|
|
serial += 1
|
|
|
|
node = renpy.game.script.lookup(renpy.game.context().current)
|
|
filename = node.filename
|
|
linenumber = node.linenumber
|
|
|
|
add_to_ast_before(s, filename, linenumber)
|
|
insert_line_before(s, filename, linenumber)
|
|
renpy.exports.restart_interaction()
|
|
|
|
|
|
def test_remove():
|
|
|
|
node = renpy.game.script.lookup(renpy.game.context().current)
|
|
filename = node.filename
|
|
linenumber = node.linenumber
|
|
|
|
remove_from_ast(filename, linenumber)
|
|
remove_line(filename, linenumber)
|
|
renpy.exports.rollback(checkpoints=0, force=True, greedy=True)
|