xref: /trunk/test/odf-validate/cov_source.py (revision 0e16a31d78a652c56c5c0fc79c752f48b9048df9)
1#**************************************************************
2#
3#  Licensed to the Apache Software Foundation (ASF) under one
4#  or more contributor license agreements.  See the NOTICE file
5#  distributed with this work for additional information
6#  regarding copyright ownership.  The ASF licenses this file
7#  to you under the Apache License, Version 2.0 (the
8#  "License"); you may not use this file except in compliance
9#  with the License.  You may obtain a copy of the License at
10#
11#    http://www.apache.org/licenses/LICENSE-2.0
12#
13#  Unless required by applicable law or agreed to in writing,
14#  software distributed under the License is distributed on an
15#  "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
16#  KIND, either express or implied.  See the License for the
17#  specific language governing permissions and limitations
18#  under the License.
19#
20#**************************************************************
21
22"""Statically extract the (namespace, local name) pairs that the ODF export
23code can emit.  Usage: python3 -I cov_source.py <repo-main-dir> <out-dir>
24
25Writes emit.tsv, emit-unresolved.tsv and emit-skipped.tsv into <out-dir>.
26"""
27
28import collections
29import os
30import re
31import sys
32
33SRC_EXT = (".cxx", ".hxx", ".h")
34SKIP_DIRS = {"test", "qa", "workben", "unotest", "transform", "unoxml", "solver"}
35SKIP_PATHS = ("filter/source/svg/",)
36KEEP_NAMES = {"impastp4.cxx"}
37IMPORT_NAME = re.compile(r"import|imp", re.I)
38
39NS_RE = re.compile(r"XML_NAMESPACE_([A-Z][A-Z0-9_]*)")
40TOK_RE = re.compile(r"\bXML_[A-Z_][A-Z0-9_]*\b")
41STR_RE = re.compile(r'"((?:[^"\\\n]|\\.)*)"')
42NO_EXPORT = re.compile(r"\bMID_FLAG_NO_PROPERTY(_EXPORT)?\b|\bMID_SW_FLAG_NO_ITEM(_EXPORT)?\b")
43ELEMENT_ROW = re.compile(r"\bMID_(SW_)?FLAG_ELEMENT_ITEM(_EXPORT)?\b")
44PROP_ROW = re.compile(r"\bMID_\w+|\bXML_(?:\w+_)?TYPE_|\bsizeof\s*\(")
45EVENT_ROW = re.compile(r'^\{\s*"[^"]*"\s*,\s*XML_NAMESPACE_\w+\s*,\s*"')
46EMIT_ATTR = ("AddAttribute", "AddAttributeASCII")
47EMIT_FUNCS = set(EMIT_ATTR) | {"AddAttributeIdLegacy", "SvXMLElementExport", "GetQNameByKey"}
48DECL_ARG = re.compile(r"^(?:const\s+|enum\s+|sal_uInt16\b|SvXMLExport\b|SvXMLAttributeList\b)")
49
50
51def read(path):
52    with open(path, errors="replace") as f:
53        return f.read()
54
55
56def mask(text):
57    """Blank comments and string contents (same length, newlines kept)."""
58    out = list(text)
59    i, n = 0, len(text)
60    while i < n:
61        c = text[i]
62        if c == "/" and text[i:i + 2] == "//":
63            j = text.find("\n", i)
64            j = n if j < 0 else j
65            for k in range(i, j):
66                out[k] = " "
67            i = j
68        elif c == "/" and text[i:i + 2] == "/*":
69            j = text.find("*/", i + 2)
70            j = n if j < 0 else j + 2
71            for k in range(i, j):
72                if text[k] != "\n":
73                    out[k] = " "
74            i = j
75        elif c in "\"'":
76            j = i + 1
77            while j < n and text[j] != c and text[j] != "\n":
78                j += 2 if text[j] == "\\" else 1
79            for k in range(i + 1, min(j, n)):
80                out[k] = " "
81            i = j + 1
82        else:
83            i += 1
84    return "".join(out)
85
86
87def directive_spans(text):
88    """Character ranges of preprocessor lines, with backslash continuations."""
89    spans = []
90    for m in re.finditer(r"^[ \t]*#(?:[^\n]*\\\n)*[^\n]*", text, re.M):
91        spans.append((m.start(), m.end()))
92    return spans
93
94
95def blank(masked, spans):
96    out = list(masked)
97    for a, b in spans:
98        for k in range(a, b):
99            if out[k] != "\n":
100                out[k] = " "
101    return "".join(out)
102
103
104def match_close(m, i):
105    """Index of the bracket closing the one at m[i]."""
106    depth = 0
107    for k in range(i, len(m)):
108        c = m[k]
109        if c in "({[":
110            depth += 1
111        elif c in ")}]":
112            depth -= 1
113            if depth == 0:
114                return k
115    return len(m) - 1
116
117
118def split_args(m, open_i, close_i):
119    """(start, end) of each top-level argument between two brackets."""
120    args, depth, start = [], 0, open_i + 1
121    for k in range(open_i + 1, close_i):
122        c = m[k]
123        if c in "({[":
124            depth += 1
125        elif c in ")}]":
126            depth -= 1
127        elif c == "," and depth == 0:
128            args.append((start, k))
129            start = k + 1
130    args.append((start, close_i))
131    return args
132
133
134def enclosing_call(m, pos):
135    """(function name, argument index, argument count) of the innermost call around pos."""
136    depth = 0
137    for k in range(pos - 1, -1, -1):
138        c = m[k]
139        if c in ")}]":
140            depth += 1
141        elif c in "({[":
142            if depth == 0:
143                if c != "(":
144                    return None
145                name = re.search(r"(\w+)\s*$", m[max(0, k - 60):k])
146                a = split_args(m, k, match_close(m, k))
147                idx = next((n for n, (x, y) in enumerate(a) if x <= pos < y), None)
148                return (name.group(1) if name else None), idx, len(a)
149            depth -= 1
150    return None
151
152
153def parse_tokens(path):
154    toks = {}
155    pat = re.compile(r'TOKEN\(\s*"((?:[^"\\]|\\.)*)"\s*,\s*(XML_\w+)\s*(?:/\*.*?\*/)?\s*\)')
156    for line in read(path).splitlines():
157        if line.lstrip().startswith("//"):
158            continue
159        m = pat.search(line)
160        if m:
161            toks[m.group(2)] = m.group(1).replace('\\"', '"')
162    return toks
163
164
165ADD_RE = re.compile(r"(?:\b_GetNamespaceMap\s*\(\s*\)\s*\.|\bmpNamespaceMap\s*->)\s*Add(?:AtIndex)?\s*\(")
166
167
168def ns_adds(masked, clean, toks):
169    """(key, URI) pairs from export-side namespace map Add/AddAtIndex calls."""
170    defs = dict(re.findall(r'^[ \t]*#[ \t]*define[ \t]+(\w+)[ \t]+"([^"]*)"', clean, re.M))
171    out = []
172    for m in ADD_RE.finditer(masked):
173        o = m.end() - 1
174        a = split_args(masked, o, match_close(masked, o))
175        if len(a) not in (3, 4):
176            continue
177        key = NS_RE.fullmatch(masked[a[-1][0]:a[-1][1]].strip())
178        arg = clean[a[-2][0]:a[-2][1]]
179        tok = re.search(r"\b(XML_N_\w+)", arg)
180        lit = next((defs[w] for w in re.findall(r"\w+", arg) if w in defs), None)
181        uri = toks.get(tok.group(1)) if tok else lit
182        if key and uri is not None:
183            out.append((key.group(1), uri))
184    return out
185
186
187class Macro:
188    def __init__(self, ns_i, ns_fixed, tok_i, tok_prefix):
189        self.ns_i, self.ns_fixed, self.tok_i, self.tok_prefix = ns_i, ns_fixed, tok_i, tok_prefix
190
191
192def parse_macros(text):
193    """Function-like macros that carry a namespace key and a token parameter."""
194    macros = {}
195    for m in re.finditer(r"^[ \t]*#[ \t]*define[ \t]+(\w+)\(([^)]*)\)((?:[^\n]|\\\n)*)", text, re.M):
196        name, params, body = m.group(1), [p.strip() for p in m.group(2).split(",")], m.group(3)
197        ns = re.search(r"XML_NAMESPACE_##(\w+)", body)
198        fixed = re.search(r"XML_NAMESPACE_([A-Z][A-Z0-9_]*)\s*,\s*(\w+)", body)
199        if ns and ns.group(1) in params:
200            ns_i = params.index(ns.group(1))
201            tok = re.search(r"(?:xmloff::token::)?XML_##(\w+)", body)
202            if tok and tok.group(1) in params:
203                macros[name] = Macro(ns_i, None, params.index(tok.group(1)), "XML_")
204                continue
205            tok = re.search(r"xmloff::token::(\w+)", body)
206            if tok and tok.group(1) in params:
207                macros[name] = Macro(ns_i, None, params.index(tok.group(1)), "")
208        elif fixed and fixed.group(2) in params:
209            macros[name] = Macro(None, fixed.group(1), params.index(fixed.group(2)), "")
210    return macros
211
212
213def included(text):
214    return re.findall(r'^[ \t]*#[ \t]*include[ \t]*["<]([^">]+)[">]', text, re.M)
215
216
217KEYWORDS = {"if", "for", "while", "switch", "catch", "return", "sizeof", "else"}
218DEF_TAIL = re.compile(r"\s*(?:const\s*)?(?:throw\s*\([^)]*\)\s*)?(?::[^{;]*)?\{")
219
220
221def func_defs(m):
222    """(name, parameter identifiers, body start, body end) of function definitions."""
223    out = []
224    for mm in re.finditer(r"\b([A-Za-z_]\w*)\s*\(", m):
225        if mm.group(1) in KEYWORDS:
226            continue
227        o = mm.end() - 1
228        c = match_close(m, o)
229        tail = DEF_TAIL.match(m, c + 1)
230        if not tail:
231            continue
232        params = []
233        for a, b in split_args(m, o, c):
234            w = re.findall(r"\w+", re.sub(r"=.*", "", m[a:b], flags=re.S))
235            params.append(w[-1] if len(w) > 1 else None)
236        b0 = tail.end() - 1
237        out.append((mm.group(1), params, b0, match_close(m, b0)))
238    return out
239
240
241class Wrapper:
242    def __init__(self, tok_i, nparams, keys, ns_param, role):
243        self.tok_i, self.nparams, self.keys, self.ns_param, self.role = tok_i, nparams, keys, ns_param, role
244
245
246class Scanner:
247    def __init__(self, main):
248        self.main = main
249        self.toks = parse_tokens(os.path.join(main, "xmloff/source/core/xmltoken.cxx"))
250        self.pairs = collections.defaultdict(lambda: {"roles": set(), "mods": set(), "ex": []})
251        self.unres, self.skipped, self.files = [], [], []
252        self.header_macros = {}
253        self.nskey_uri_global = {}
254        self.ns_local = {}
255        self.wrappers = collections.defaultdict(list)
256        self.cache = {}
257        self.funcs = {}
258
259    def walk(self):
260        out = []
261        for d, ds, fs in os.walk(self.main):
262            rel = os.path.relpath(d, self.main).replace(os.sep, "/") + "/"
263            ds[:] = [x for x in ds if x not in SKIP_DIRS]
264            if any(p in rel for p in SKIP_PATHS):
265                continue
266            for f in fs:
267                if f.endswith(SRC_EXT):
268                    out.append(os.path.join(d, f))
269        return sorted(out)
270
271    @staticmethod
272    def eligible(path):
273        base = os.path.basename(path)
274        return base.lower() in KEEP_NAMES or not IMPORT_NAME.search(base)
275
276    def uri(self, key, path):
277        key = re.sub(r"_IDX$", "", key)
278        uri = self.ns_local.get(path, {}).get(key) or self.nskey_uri_global.get(key)
279        if uri:
280            return uri
281        if key == "NONE":
282            return ""
283        if key == "XML":
284            return self.toks["XML_N_XML"]
285        return None
286
287    def prepare(self):
288        texts = {}
289        for p in self.walk():
290            t = read(p)
291            if "XML_NAMESPACE_" not in t:
292                continue
293            texts[p] = t
294        for p, t in texts.items():
295            if p.endswith((".hxx", ".h")):
296                mc = parse_macros(t)
297                if mc:
298                    self.header_macros[os.path.basename(p)] = mc
299        first = {}
300        for p, t in sorted(texts.items(), key=lambda kv: kv[0]):
301            if not self.eligible(p):
302                continue
303            self.files.append(p)
304            m = mask(t)
305            for key, tok in ns_adds(m, t, self.toks):
306                self.ns_local.setdefault(p, {})[key] = tok
307                if p.endswith("xmloff/source/core/xmlexp.cxx"):
308                    self.nskey_uri_global[key] = tok
309                else:
310                    first.setdefault(key, tok)
311        for key, tok in first.items():
312            self.nskey_uri_global.setdefault(key, tok)
313        self.texts = texts
314
315    def names(self, arg_masked, arg_clean):
316        """Names in a name argument, and whether anything was left unresolved."""
317        found, rest = [], arg_masked
318        for m in STR_RE.finditer(arg_clean):
319            if m.group(1):
320                found.append(m.group(1))
321        for m in TOK_RE.finditer(arg_masked):
322            if m.group(0) in self.toks and not m.group(0).startswith(("XML_NP_", "XML_N_")):
323                found.append(self.toks[m.group(0)])
324        if not found and re.search(r"\bXML_NP?_[A-Z]", arg_masked):
325            return [], False
326        rest = TOK_RE.sub(" ", STR_RE.sub(" ", arg_clean))
327        rest = re.sub(r"(?:::)?(?:xmloff::token::|token::|GetXMLToken|RTL_CONSTASCII_USTRINGPARAM|"
328                      r"OUString|createFromAscii|rtl::|sal_Char|static_cast<[^>]*>)", " ", rest)
329        rest = re.sub(r"[\s()?:]|\bsal_True\b|\bsal_False\b", "", rest)
330        return found, bool(rest) or not found
331
332    def record(self, path, line, key, names, role, row):
333        uri = self.uri(key, path)
334        if uri is None:
335            self.unres.append((path, line, "unmapped namespace key " + key, row))
336            return
337        mod = os.path.relpath(path, self.main).split(os.sep)[0]
338        for n in names:
339            e = self.pairs[(uri, n)]
340            e["roles"].add(role)
341            e["mods"].add(mod)
342            e["ex"].append((os.path.relpath(path, self.main), line, role))
343
344    def scan_file(self, path):
345        text = self.texts[path]
346        spans = directive_spans(text)
347        m = blank(mask(text), spans)
348        clean = blank(text, spans)
349        macros = {}
350        for inc in included(text):
351            macros.update(self.header_macros.get(os.path.basename(inc), {}))
352        macros.update(parse_macros(text))
353        self.cache[path] = (m, clean, text)
354        line_of = lambda i: text.count("\n", 0, i) + 1
355        snip = lambda a, b: " ".join(clean[a:b].split())[:160]
356        handled = set()
357        opener = self.openers(m)
358
359        funcs = self.funcs[path] = func_defs(m)
360        tokmaps = [(t.end(), match_close(m, t.end() - 1))
361                   for t in re.finditer(r"SvXMLTokenMapEntry\s+\w+\s*\[[^\]]*\]\s*=\s*\{", m)]
362        in_tokmap = lambda i: any(a <= i < b for a, b in tokmaps)
363
364        def wrap(ns_a, ns_b, nm_a, nm_b, role, i):
365            """Register the enclosing function as a wrapper when it forwards its parameters."""
366            inner = [f for f in funcs if f[2] < i < f[3]]
367            if not inner:
368                return False
369            name, params, b0, b1 = min(inner, key=lambda f: f[3] - f[2])
370            nm, ns = m[nm_a:nm_b].strip(), m[ns_a:ns_b].strip()
371            if nm not in params or name in EMIT_FUNCS or role == "either":
372                return False
373            if NS_RE.fullmatch(ns):
374                keys, ns_param = [NS_RE.fullmatch(ns).group(1)], None
375            elif ns in params:
376                keys, ns_param = None, ns
377            else:
378                return False
379            module = os.path.relpath(path, self.main).split(os.sep)[0]
380            self.wrappers[(module, name)].append(
381                Wrapper(params.index(nm), len(params), keys, params.index(ns) if ns_param else None, role))
382            return True
383
384        def pair_from(ns_a, ns_b, nm_a, nm_b, role, ctx_a, ctx_b, i, fixed=None, wrapok=True):
385            if in_tokmap(i):
386                return
387            keys = NS_RE.findall(m[ns_a:ns_b])
388            names, bad = (fixed, False) if fixed else self.names(m[nm_a:nm_b], clean[nm_a:nm_b])
389            row = snip(ctx_a, ctx_b)
390            for k in NS_RE.finditer(m[ns_a:ns_b]):
391                handled.add(ns_a + k.start())
392            if (not keys or bad) and wrapok and not fixed and wrap(ns_a, ns_b, nm_a, nm_b, role, i):
393                return
394            if not keys:
395                self.unres.append((path, line_of(i), "namespace not a literal key", row))
396                return
397            if bad:
398                self.unres.append((path, line_of(i), "name not a literal token or string", row))
399            if not names:
400                return
401            if NO_EXPORT.search(clean[ctx_a:ctx_b]):
402                self.skipped.append((path, line_of(i), "not exported (flags)", row))
403                return
404            for k in keys:
405                self.record(path, line_of(i), k, names, role, row)
406
407        for name, params, b0, b1 in funcs:
408            ns_fn = [f for f in funcs if f[0] == re.sub(r"Name$", "Namespace", name) and name.endswith("AttributeName")]
409            if not ns_fn:
410                continue
411            ns_body = clean[ns_fn[0][2]:ns_fn[0][3]]
412            default = re.findall(r"return\s+XML_NAMESPACE_([A-Z][A-Z0-9_]*)\s*;", ns_body)[-1:]
413            special = {(y if x in ns_fn[0][1] else x): k for x, y, k in re.findall(
414                r"if\s*\(\s*(\w+)\s*==\s*(\w+)\s*\)\s*return\s+XML_NAMESPACE_(\w+)", ns_body)}
415            for cm in re.finditer(r'case\s+(\w+)\s*:\s*return\s+"([^"]+)"', clean[b0:b1]):
416                key = special.get(cm.group(1)) or default and default[0]
417                if key:
418                    self.record(path, line_of(b0 + cm.start()), key, [cm.group(2)], "attribute", cm.group(0))
419
420        for mm in re.finditer(r"\b(%s)\s*\(" % "|".join(EMIT_ATTR), m):
421            o = mm.end() - 1
422            c = match_close(m, o)
423            a = split_args(m, o, c)
424            if len(a) < 2 or (len(a) == 2 and "XML_NAMESPACE_" not in m[a[0][0]:a[0][1]]):
425                continue
426            if DECL_ARG.match(m[a[0][0]:a[0][1]].strip()):
427                continue
428            pair_from(a[0][0], a[0][1], a[1][0], a[1][1], "attribute", mm.start(), c + 1, mm.start())
429
430        for mm in re.finditer(r"\bSvXMLElementExport\b\s*(?:\w+\s*)?\(", m):
431            o = mm.end() - 1
432            c = match_close(m, o)
433            a = split_args(m, o, c)
434            k = 2 if len(a) >= 6 else 1
435            if len(a) < 3 or len(a) <= k + 1 or DECL_ARG.match(m[a[k][0]:a[k][1]].strip()):
436                continue
437            pair_from(a[k][0], a[k][1], a[k + 1][0], a[k + 1][1], "element", mm.start(), c + 1, mm.start())
438
439        for mm in re.finditer(r"\bAddAttributeIdLegacy\s*\(", m):
440            o = mm.end() - 1
441            c = match_close(m, o)
442            a = split_args(m, o, c)
443            if len(a) == 2 and not DECL_ARG.match(m[a[0][0]:a[0][1]].strip()):
444                pair_from(a[0][0], a[0][1], 0, 0, "attribute", mm.start(), c + 1, mm.start(), ["id"])
445
446        for mm in re.finditer(r"\bGetQNameByKey\s*\(", m):
447            o = mm.end() - 1
448            c = match_close(m, o)
449            a = split_args(m, o, c)
450            up = enclosing_call(m, mm.start())
451            if up and up[0] in EMIT_ATTR and up[1] >= (1 if up[2] == 2 else 2):
452                self.skipped.append((path, line_of(mm.start()), "QName used as an attribute value", snip(mm.start(), c + 1)))
453                for k in NS_RE.finditer(m[a[0][0]:a[0][1]]):
454                    handled.add(a[0][0] + k.start())
455            elif len(a) == 2 and STR_RE.search(clean[a[1][0]:a[1][1]]) and not TOK_RE.search(m[a[1][0]:a[1][1]]):
456                self.skipped.append((path, line_of(mm.start()), "QName with literal local name (a value)", snip(mm.start(), c + 1)))
457            elif len(a) == 2:
458                pair_from(a[0][0], a[0][1], a[1][0], a[1][1], "either", mm.start(), c + 1, mm.start(), wrapok=False)
459
460        for name, mac in macros.items():
461            for mm in re.finditer(r"\b%s\s*\(" % re.escape(name), m):
462                o = mm.end() - 1
463                c = match_close(m, o)
464                a = split_args(m, o, c)
465                if len(a) <= max(mac.ns_i or 0, mac.tok_i):
466                    continue
467                if in_tokmap(mm.start()):
468                    continue
469                row = snip(mm.start(), c + 1)
470                ns = mac.ns_fixed or m[a[mac.ns_i][0]:a[mac.ns_i][1]].strip()
471                tk = mac.tok_prefix + clean[a[mac.tok_i][0]:a[mac.tok_i][1]].strip()
472                names, bad = self.names(tk, tk)
473                if not re.fullmatch(r"[A-Z][A-Z0-9_]*", ns) or bad:
474                    self.unres.append((path, line_of(mm.start()), "macro argument not a known key/token", row))
475                    continue
476                body = clean[mm.start():c]
477                if NO_EXPORT.search(body):
478                    self.skipped.append((path, line_of(mm.start()), "not exported (flags)", row))
479                    continue
480                role = "element" if ELEMENT_ROW.search(body) else "attribute" if PROP_ROW.search(body) else "either"
481                self.record(path, line_of(mm.start()), ns, names, role, row)
482
483        for mm in NS_RE.finditer(m):
484            if mm.start() in handled:
485                continue
486            j = mm.end()
487            while j < len(m) and m[j] in " \t\r\n":
488                j += 1
489            if j >= len(m) or m[j] != ",":
490                continue
491            br = opener.get(mm.start())
492            if br is None:
493                continue
494            bi, bc = br
495            before = re.search(r"(\w+)\s*(\w*)\s*$", m[max(0, bi - 80):bi])
496            if m[bi] == "(" and before and (before.group(1) in EMIT_FUNCS or before.group(1) == "SvXMLElementExport"
497                                            or before.group(2) and before.group(1) == "SvXMLElementExport"):
498                continue
499            a = split_args(m, bi, bc)
500            idx = next(n for n, (s, e) in enumerate(a) if s <= mm.start() < e)
501            if idx + 1 >= len(a) or m[a[idx][0]:a[idx][1]].strip() != mm.group(0):
502                continue
503            row_clean = clean[bi:bc + 1]
504            if m[bi] == "{" and re.search(r"\bXML_TOK_\w+", row_clean):
505                continue
506            if EVENT_ROW.match(row_clean):
507                self.skipped.append((path, line_of(mm.start()), "event-name translation (a value, not a name)", snip(bi, bc + 1)))
508                continue
509            if m[bi] == "{" or PROP_ROW.search(row_clean):
510                role = "element" if ELEMENT_ROW.search(row_clean) else "attribute"
511                if not PROP_ROW.search(row_clean):
512                    role = "either"
513            else:
514                role = "either"
515            pair_from(a[idx][0], a[idx][1], a[idx + 1][0], a[idx + 1][1], role, bi, bc + 1, mm.start(), wrapok=False)
516
517    def ns_default(self, module, fname, ns_param_i):
518        """Default namespace key(s) of the ns parameter of fname, from its declarations."""
519        out = set()
520        for p, (m, clean, text) in self.cache.items():
521            if os.path.relpath(p, self.main).split(os.sep)[0] != module:
522                continue
523            for mm in re.finditer(r"\b%s\s*\(" % re.escape(fname), m):
524                o = mm.end() - 1
525                a = split_args(m, o, match_close(m, o))
526                if ns_param_i < len(a):
527                    d = re.search(r"=\s*XML_NAMESPACE_([A-Z][A-Z0-9_]*)", m[a[ns_param_i][0]:a[ns_param_i][1]])
528                    if d:
529                        out.add(d.group(1))
530        return out
531
532    def scan_wrapper_calls(self, path, discover):
533        """Resolve calls of wrapper functions; with discover, only register forwarders."""
534        m, clean, text = self.cache[path]
535        module = os.path.relpath(path, self.main).split(os.sep)[0]
536        line_of = lambda i: text.count("\n", 0, i) + 1
537        found = 0
538        for (mod, fname), ws in list(self.wrappers.items()):
539            if mod != module:
540                continue
541            for mm in re.finditer(r"\b%s\s*\(" % re.escape(fname), m):
542                o = mm.end() - 1
543                c = match_close(m, o)
544                a = split_args(m, o, c)
545                a0 = m[a[0][0]:a[0][1]].strip()
546                if DEF_TAIL.match(m, c + 1) or re.fullmatch(r"[\w:]+(?:\s+[\w:*&]+)+\s*(=.*)?", a0, re.S):
547                    continue
548                row = " ".join(clean[mm.start():c + 1].split())[:160]
549                res, fwd = set(), []
550                for w in ws:
551                    if not (w.tok_i < len(a) <= w.nparams):
552                        continue
553                    ta, tb = a[w.tok_i]
554                    names, bad = self.names(m[ta:tb], clean[ta:tb])
555                    if w.keys:
556                        keys = tuple(w.keys)
557                    elif w.ns_param < len(a):
558                        k = NS_RE.fullmatch(m[a[w.ns_param][0]:a[w.ns_param][1]].strip())
559                        keys = (k.group(1),) if k else None
560                    else:
561                        d = self.ns_default(module, fname, w.ns_param)
562                        keys = tuple(d) if len(d) == 1 else None
563                    if bad and keys:
564                        fwd.append((m[ta:tb].strip(), keys, w.role))
565                    res.add(None if bad or not keys else (tuple(names), keys, w.role))
566                if discover:
567                    inner = [f for f in self.funcs[path] if f[2] < mm.start() < f[3]]
568                    if inner and len(fwd) == 1 and len(res) == 1:
569                        name, params, b0, b1 = min(inner, key=lambda f: f[3] - f[2])
570                        if fwd[0][0] in params and name not in EMIT_FUNCS:
571                            nw = Wrapper(params.index(fwd[0][0]), len(params), list(fwd[0][1]), None, fwd[0][2])
572                            old = self.wrappers[(module, name)]
573                            if not any(x.tok_i == nw.tok_i and x.nparams == nw.nparams and x.keys == nw.keys for x in old):
574                                old.append(nw)
575                                found += 1
576                    continue
577                if len(res) != 1 or None in res:
578                    if res and not (len(fwd) == 1 and any(f[2] < mm.start() < f[3] and fwd[0][0] in f[1] for f in self.funcs[path])):
579                        self.unres.append((path, line_of(mm.start()), "wrapper %s() call not resolvable" % fname, row))
580                    continue
581                names, keys, role = res.pop()
582                for k in keys:
583                    self.record(path, line_of(mm.start()), k, list(names), role, row)
584        return found
585
586    @staticmethod
587    def openers(m):
588        """Map every XML_NAMESPACE_ match start to its innermost enclosing bracket."""
589        starts = {x.start() for x in NS_RE.finditer(m)}
590        out, stack = {}, []
591        pairs = {}
592        for i, c in enumerate(m):
593            if c in "({":
594                stack.append(i)
595            elif c in ")}":
596                if stack:
597                    pairs[stack.pop()] = i
598            elif i in starts:
599                out[i] = stack[-1] if stack else None
600        res = {}
601        for s, o in out.items():
602            if o is not None and o in pairs:
603                res[s] = (o, pairs[o])
604        return res
605
606    def write(self, outdir):
607        os.makedirs(outdir, exist_ok=True)
608        rows = []
609        for (uri, name), e in self.pairs.items():
610            known = e["roles"] - {"either"}
611            role = known.pop() if len(known) == 1 else "either"
612            ex = sorted(e["ex"], key=lambda x: (x[2] == "either", x[0], x[1]))[0]
613            rows.append((uri, name, role, ",".join(sorted(e["mods"])), "%s:%d" % ex[:2]))
614        rows.sort()
615        with open(os.path.join(outdir, "emit.tsv"), "w") as f:
616            f.write("# ODF export names found statically in %d export-side C++ files under main/.\n" % len(self.files))
617            f.write("# Rule: .cxx/.hxx/.h under main/ whose name has no 'imp'/'import' (impastp4.cxx kept), outside test/qa/workben/unotest/unoxml/solver,"
618                    " xmloff/source/transform and filter/source/svg; shared tables (property maps, handlers) included;"
619                    " import token-map rows (XML_TOK_) and rows flagged no-export dropped.\n")
620            f.write("# role: element/attribute from the emitting call or property-map flags; either = unknown or seen as both."
621                    " Empty ns_uri = XML_NAMESPACE_NONE.\n")
622            f.write("# ns_uri\tlocal\trole\tmodules\texample_file:line\n")
623            for r in rows:
624                f.write("\t".join(r) + "\n")
625        for name, lst in (("emit-unresolved.tsv", self.unres), ("emit-skipped.tsv", self.skipped)):
626            with open(os.path.join(outdir, name), "w") as f:
627                f.write("# file:line\treason\tsnippet\n")
628                for p, ln, why, row in sorted(set(lst)):
629                    f.write("%s:%d\t%s\t%s\n" % (os.path.relpath(p, self.main), ln, why, row))
630        return rows
631
632
633def main(argv):
634    if len(argv) != 3:
635        sys.exit(__doc__)
636    sc = Scanner(argv[1])
637    sc.prepare()
638    for p in sc.files:
639        sc.scan_file(p)
640    for _ in range(5):
641        if not sum(sc.scan_wrapper_calls(p, True) for p in sc.files):
642            break
643    for p in sc.files:
644        sc.scan_wrapper_calls(p, False)
645    rows = sc.write(argv[2])
646    roles = collections.Counter(r[2] for r in rows)
647    print("tokens in xmltoken.cxx: %d; files scanned: %d" % (len(sc.toks), len(sc.files)))
648    print("distinct pairs: %d %s" % (len(rows), dict(roles)))
649    print("unresolved sites: %d; skipped rows: %d" % (len(set(sc.unres)), len(set(sc.skipped))))
650
651
652if __name__ == "__main__":
653    main(sys.argv)
654