wmlscope: detect and analyze optional macro arguments

This commit is contained in:
Elvish_Hunter 2019-12-02 21:23:58 +01:00
parent cba53cd471
commit ec41359368
4 changed files with 111 additions and 40 deletions

View file

@ -91,6 +91,7 @@
* Added tool `tmx_trackplacer`, a file converter for editing map tracks with Tiled (PR #4464)
* Added tool trackviewer, which has the animation-preview functions of trackplacer (PR #4574)
* Removed the python2 trackplacer tool (issue #4365)
* Made wmlscope recognize and analyze optional macro arguments
## Version 1.15.2
### AI:

View file

@ -287,9 +287,33 @@ def isresource(filename):
return ext and ext[1:] in resource_extensions
def parse_macroref(start, line):
def handle_argument():
nonlocal opt_arg
nonlocal arg
nonlocal optional_args
nonlocal args
arg = arg.strip()
# is this an optional argument?
# argument names are usually made of uppercase letters, numbers and underscores
# if they're optional, they're followed by an equal sign
# stop matching on the first one, because the argument value might contain one too
if re.match(r"^([A-Z0-9_]+?)=", arg):
opt_arg, arg = arg.split("=", 1)
if arg.startswith('"') and arg.endswith('"'):
arg = arg[1:-1].strip()
if opt_arg:
optional_args[opt_arg] = arg
opt_arg = ""
else:
args.append(arg)
arg = ""
brackdepth = parendepth = 0
instring = False
optional_args = {}
args = []
opt_arg = ""
arg = ""
for i in range(start, len(line)):
if instring:
@ -307,11 +331,7 @@ def parse_macroref(start, line):
brackdepth -= 1
if brackdepth == 0:
if not line[i-1].isspace():
arg = arg.strip()
if arg.startswith('"') and arg.endswith('"'):
arg = arg[1:-1].strip()
args.append(arg)
arg = ""
handle_argument()
break
else:
arg += line[i]
@ -323,14 +343,10 @@ def parse_macroref(start, line):
line[i].isspace() and \
brackdepth == 1 and \
parendepth == 0:
arg = arg.strip()
if arg.startswith('"') and arg.endswith('"'):
arg = arg[1:-1].strip()
args.append(arg)
arg = ""
handle_argument()
elif not line[i].isspace() or parendepth > 0:
arg += line[i]
return (args, brackdepth, parendepth)
return (args, optional_args, brackdepth, parendepth)
def formaltype(f):
# Deduce the expected type of the formal
@ -419,10 +435,18 @@ def actualtype(a):
atype = "string"
return atype
def argmatch(formals, actuals):
def argmatch(formals, optional_formals, actuals, optional_actuals):
if optional_formals:
for key in optional_actuals.keys():
if key not in optional_formals:
return False
if len(formals) != len(actuals):
return False
for (f, a) in zip(formals, actuals):
opt_formals, opt_actuals = [], []
for key, value in optional_actuals.items():
opt_formals.append(key)
opt_actuals.append(value)
for (f, a) in zip(formals + opt_formals, actuals + opt_actuals):
# Here's the compatibility logic. First, we catch the situations
# in which a more restricted actual type matches a more general
# formal one. Then we have a fallback rule checking for type
@ -465,17 +489,18 @@ def argmatch(formals, actuals):
@total_ordering
class Reference:
"Describes a location by file and line."
def __init__(self, namespace, filename, lineno=None, docstring=None, args=None):
def __init__(self, namespace, filename, lineno=None, docstring=None, args=None, optional_args=None):
self.namespace = namespace
self.filename = filename
self.lineno = lineno
self.docstring = docstring
self.args = args
self.optional_args = optional_args
self.references = collections.defaultdict(list)
self.undef = None
def append(self, fn, n, a=None):
self.references[fn].append((n, a))
def append(self, fn, n, args=None, optional_args=None):
self.references[fn].append((n, args, optional_args))
def dump_references(self):
"Dump all known references to this definition."
@ -495,10 +520,10 @@ class Reference:
return self.filename > other.filename
def mismatches(self):
copy = Reference(self.namespace, self.filename, self.lineno, self.docstring, self.args)
copy = Reference(self.namespace, self.filename, self.lineno, self.docstring, self.args, self.optional_args)
copy.undef = self.undef
for filename in self.references:
mis = [(ln,a) for (ln,a) in self.references[filename] if a is not None and not argmatch(self.args, a)]
mis = [(ln,a,oa) for (ln,a,oa) in self.references[filename] if a is not None and not argmatch(self.args, self.optional_args, a, oa)]
if mis:
copy.references[filename] = mis
return copy
@ -617,7 +642,7 @@ class CrossRef:
% (filename, n+1), file=sys.stderr)
else:
name = tokens[1]
here = Reference(namespace, filename, n+1, line, args=tokens[2:])
here = Reference(namespace, filename, n+1, line, args=tokens[2:], optional_args=[])
here.hash = hashlib.md5()
here.docstring = line.lstrip()[8:] # Strip off #define_
state = "macro_header"
@ -641,13 +666,19 @@ class CrossRef:
self.xref[name] = []
self.xref[name].append(here)
state = "outside"
elif state == "macro_header" and line.strip() and line.strip()[0] != "#":
state = "macro_body"
elif state == "macro_header" and line.strip():
if line.strip().startswith("#arg"):
state = "macro_optional_argument"
here.optional_args.append(line.strip().split()[1])
elif line.strip()[0] != "#":
state = "macro_body"
elif state == "macro_optional_argument" and "#endarg" in line:
state = "macro_header"
if state == "macro_header":
# Ignore macro header commends that are pragmas
if "wmlscope" not in line and "wmllint:" not in line:
here.docstring += line.lstrip()[1:]
if state in ("macro_header", "macro_body"):
if state in ("macro_header", "macro_optional_argument", "macro_body"):
here.hash.update(line.encode("utf8"))
elif line.strip().startswith("#undef"):
tokens = line.split()
@ -712,6 +743,7 @@ class CrossRef:
self.unresolved = []
self.missing = []
formals = []
optional_formals = []
state = "outside"
if self.warnlevel >=2 or progress:
print("*** Beginning reference-gathering pass...")
@ -724,11 +756,17 @@ class CrossRef:
have_icon = False
beneath = 0
ignoreflag = False
in_macro_definition = False
for (n, line) in enumerate(rfp):
if line.strip().startswith("#define"):
formals = line.strip().split()[2:]
in_macro_definition = True
elif line.startswith("#enddef"):
formals = []
optional_formals = []
in_macro_definition = False
elif in_macro_definition and line.startswith("#arg"):
optional_formals.append(line.strip().split()[1])
comment = ""
if '#' in line:
if "# wmlscope: start ignoring" in line:
@ -760,15 +798,16 @@ class CrossRef:
if self.warnlevel >=2:
print('"%s", line %d: seeking definition of %s' \
% (fn, n+1, name))
if name in formals:
if name in formals or name in optional_formals:
continue
elif name in self.xref:
# Count the number of actual arguments.
# Set args to None if the call doesn't
# close on this line
(args, brackdepth, parendepth) = parse_macroref(match.start(0), line)
(args, optional_args, brackdepth, parendepth) = parse_macroref(match.start(0), line)
if brackdepth > 0 or parendepth > 0:
args = None
optional_args = None
else:
args.pop(0)
#if args:
@ -777,7 +816,7 @@ class CrossRef:
# Figure out which macros might resolve this
for defn in self.xref[name]:
if self.visible_from(defn, fn, n+1):
defn.append(fn, n+1, args)
defn.append(fn, n+1, args, optional_args)
candidates.append(str(defn))
if len(candidates) > 1:
print("%s: more than one definition of %s is visible here (%s)." % (Reference(ns, fn, n), name, "; ".join(candidates)))

View file

@ -2048,12 +2048,12 @@ def global_sanity_check(filename, lines):
# this here rather than hack_syntax so the character can be
# recognized.
if macname == 'LOYAL_UNIT':
(args, brack, paren) = parse_macroref(0, leadmac.string)
(args, optional_args, brack, paren) = parse_macroref(0, leadmac.string)
if len(args) == 7:
lines[i] = lines[i].replace('{LOYAL_UNIT', '{NAMED_LOYAL_UNIT', 1)
# Auto-recognize the people in the {NAMED_*UNIT} macros.
if re.match(r'NAMED_[A-Z_]*UNIT$', macname):
(args, brack, paren) = parse_macroref(0, leadmac.string)
(args, optional_args, brack, paren) = parse_macroref(0, leadmac.string)
if len(args) >= 7 and \
re.match(r'([0-9]+|[^\s]*\$[^\s]*side[^\s]*|{[^\s]*SIDE[^\s]*})$', args[1]) and \
re.match(r'([0-9]+|[^\s]*\$[^\s]*x[^\s]*|{[^\s]*X[^\s]*})$', args[3]) and \
@ -2061,20 +2061,20 @@ def global_sanity_check(filename, lines):
len(args[5]) > 0:
present.append(args[5])
elif macname == 'RECALL':
(args, brack, paren) = parse_macroref(0, leadmac.string)
(args, optional_args, brack, paren) = parse_macroref(0, leadmac.string)
if len(args) == 2 and brack == 0:
present.append(args[1])
elif macname == 'RECALL_XY':
(args, brack, paren) = parse_macroref(0, leadmac.string)
(args, optional_args, brack, paren) = parse_macroref(0, leadmac.string)
if len(args) == 4:
present.append(args[1])
elif macname == 'CLEAR_VARIABLE':
(args, brack, paren) = parse_macroref(0, leadmac.string)
(args, optional_args, brack, paren) = parse_macroref(0, leadmac.string)
for arg in [x.lstrip() for x in args[1].split(',')]:
if arg in storedids:
del storedids[arg]
elif macname in whomacros:
(args, brack, paren) = parse_macroref(0, leadmac.string)
(args, optional_args, brack, paren) = parse_macroref(0, leadmac.string)
present.append(args[whomacros[macname]])
m = re.search("# *wmllint: recognize +(.*)", lines[i])
if m:

View file

@ -175,10 +175,23 @@ class CrossRefLister(CrossRef):
if mismatched:
print("# Mismatched references:")
for (n, m) in mismatched:
print("%s: macro %s(%s) has mismatches:" % (m, n, ", ".join(["{}={}".format(x, formaltype(x)) for x in m.args])))
print("%s: macro %s(%s%s%s) has mismatches:" % (m,
n,
", ".join(["{}={}".format(x, formaltype(x)) for x in m.args]),
" " if m.optional_args else "",
", ".join(["{}=optional {}".format(x, formaltype(x)) for x in m.optional_args])))
for (file, refs) in m.references.items():
for (ln, args) in refs:
print('"%s", line %d: %s(%s) with signature (%s)' % (file, ln, n, ", ".join(args), ", ".join(["{}={}".format(f, actualtype(a)) for f,a in zip(m.args, args)])))
for (ln, args, optional_args) in refs:
print('"%s", line %d: %s(%s%s%s) with signature (%s%s%s)' %
(file,
ln,
n,
", ".join(args),
", " if optional_args else "",
", ".join(optional_args.values()),
", ".join(["{}={}".format(f, actualtype(a)) for f,a in zip(m.args, args)]),
", " if optional_args else "",
", ".join(["{}=optional {}".format(f, actualtype(oa)) for f,oa in optional_args.items()])))
def incorrectlysized(self):
"Report incorrectly sized images that cannot be safely used for their intended purpose"
@ -258,15 +271,33 @@ class CrossRefLister(CrossRef):
if filename.endswith(branch):
if name not in already_seen:
already_seen.append(name)
print("%s: macro %s(%s):" % (defn, name, ", ".join(["{}={}".format(x, formaltype(x)) for x in defn.args])))
for (ln, args) in refs:
print('"%s", line %d: %s(%s) with signature (%s)' % (filename, ln, name, ", ".join(args), ", ".join(["{}={}".format(f, actualtype(a)) for f,a in zip(defn.args, args)])))
print("%s: macro %s(%s%s%s):" %
(defn,
name,
", ".join(["{}={}".format(x, formaltype(x)) for x in defn.args]),
", " if defn.optional_args else "",
", ".join(["{}=optional {}".format(x, formaltype(x)) for x in defn.optional_args])))
for (ln, args, optional_args) in refs:
print('"%s", line %d: %s(%s%s%s) with signature (%s%s%s)' %
(filename,
ln,
name,
", ".join(args),
", " if optional_args else "",
", ".join(optional_args.values()),
", ".join(["{}={}".format(f, actualtype(a)) for f,a in zip(defn.args, args)]),
", " if optional_args else "",
", ".join(["{}={}".format(f, actualtype(oa)) for f,oa in optional_args.items()])))
def deflist(self, pred=None):
"List all resource definitions."
for name in sorted(self.xref.keys()):
for defn in self.xref[name]:
if not pred or pred(name, defn):
print("macro", name, " ".join(["{}={}".format(x, formaltype(x)) for x in defn.args]))
print("macro",
name,
" ".join(["{}={}".format(x, formaltype(x)) for x in defn.args]),
" ".join(["{}=optional {}".format(x, formaltype(x)) for x in defn.optional_args]))
for name in sorted(self.fileref.keys()):
defloc = self.fileref[name]
if not pred or pred(name, defloc):
@ -291,7 +322,7 @@ class CrossRefLister(CrossRef):
unchecked.append((name, defn))
unresolvedcount += len(defn.references)
if unchecked:
print("# %d of %d (%.02f%%) macro definitions and %d of %d calls (%.02f%%) have untyped formals:" \
print("# %d of %d (%.02f%%) macro definitions and %d of %d calls (%.02f%%) have untyped required formals:" \
% (len(unchecked),
defcount,
((100 * len(unchecked)) / defcount),
@ -301,7 +332,7 @@ class CrossRefLister(CrossRef):
# sort by checking the 2nd element in the tuple
unchecked.sort(key=lambda element: element[1])
for (name, defn) in unchecked:
print("%s: %s(%s)" % (defn, name, ", ".join(defn.args)))
print("%s: %s(%s)" % (defn, name, ", ".join(defn.args),))
def extracthelp(self, pref, fp):
"Deliver all macro help comments in HTML form."