diff --git a/test/shell.d/qml-text-format-scan.py b/test/shell.d/qml-text-format-scan.py new file mode 100644 index 00000000..d405e06c --- /dev/null +++ b/test/shell.d/qml-text-format-scan.py @@ -0,0 +1,373 @@ +"""Report every QML Text that renders a non-literal value without a textFormat. + +Usage: qml-text-format-scan.py ROOT (scans ROOT/shell, prints one line per +violation, exits 1 on an unreadable tree). Lives in its own file rather than a +heredoc so the test can run it over fixtures and prove it still fails when it +should — a guard nothing can fail is a guard nobody should trust. + +Two limits are deliberate, because a line scanner cannot close them. It reads +each Text element's own declaration, so text assigned from somewhere else — +`Binding { target: label; property: "text" }`, `PropertyChanges`, a +`Component.onCompleted` assignment, a `property alias` onto a child's text — +is invisible to it. And a regex literal containing a brace throws off the brace +depth. Neither shape exists in this tree; both would need a QML parser. +""" + +import os +import re +import sys +from pathlib import Path + +BLOCK_COMMENT = re.compile(r'/\*.*?\*/|/\*.*\Z', re.S) + + +def strip_block_comments(text): + """Blank out /* */ comments, keeping every newline so line numbers hold. + + strip_noise() only knows `//`, so before this a block comment between a + type name and its brace — `Text /* why */ {` — hid the element from + OPEN_ELEMENT and from the unscannable-form check alike, and the block + passed with no textFormat at all. + """ + out = [] + i = 0 + quote = None + while i < len(text): + c = text[i] + if quote: + if c == '\\': + out.append(text[i:i + 2]) + i += 2 + continue + if c == quote: + quote = None + out.append(c) + i += 1 + continue + if c in '"\'': + quote = c + out.append(c) + i += 1 + continue + if c == '/' and text.startswith('//', i): + end = text.find('\n', i) + if end == -1: + break + out.append(text[i:end]) + i = end + continue + if c == '/' and text.startswith('/*', i): + end = text.find('*/', i + 2) + end = len(text) if end == -1 else end + 2 + out.append(''.join(ch if ch == '\n' else ' ' for ch in text[i:end])) + i = end + continue + out.append(c) + i += 1 + return ''.join(out) + + +# A Text under a namespaced import — `import QtQuick as QQ` then `QQ.Text` — is +# the same element and was skipped, because the name compared unequal to `Text`. +TEXT_NAME = r'(?:[A-Za-z_][A-Za-z0-9_]*\.)?Text' + +OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') +INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{\s*$') +INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*' + TEXT_NAME + r'\s*\{') +PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') +STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') +PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') +# A binding that runs onto the next line: this line ends on an operator, or the +# next line opens with one. +TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$') +LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])') + + +def strip_noise(line, keep_strings=False): + out = [] + i = 0 + quote = None + while i < len(line): + c = line[i] + if quote: + if keep_strings: + out.append(c) + if c == '\\': + if keep_strings and i + 1 < len(line): + out.append(line[i + 1]) + i += 2 + continue + if c == quote: + quote = None + if not keep_strings: + out.append('S') + i += 1 + continue + if c in '"\'': + quote = c + if keep_strings: + out.append(c) + i += 1 + continue + if c == '/' and i + 1 < len(line) and line[i + 1] == '/': + break + out.append(c) + i += 1 + return ''.join(out) + + +def is_pure_literal(expr): + residue = STRING_LITERAL.sub('', expr) + residue = re.sub(r'[\s+]', '', residue) + return residue == '' and STRING_LITERAL.search(expr) is not None + + +def binding_expression(lines, start): + """The whole right-hand side of the binding beginning on line `start`. + + The literal exemption has to be judged on the complete expression. Reading + only the physical `text:` line would exempt `text: "prefix"` while + `+ externalValue` sits underneath, letting a dynamic AutoText binding + through. Reading a wrapped concatenation of literals as dynamic would be + the opposite error, so follow the expression to its end either way. + """ + parts = [] + parens = brackets = 0 + i = start + while i < len(lines): + parts.append(strip_noise(lines[i], keep_strings=True)) + counted = strip_noise(lines[i]) + parens += counted.count('(') - counted.count(')') + brackets += counted.count('[') - counted.count(']') + # Look past blank and comment-only lines for the continuation. A + # comment or a blank line dropped into a wrapped expression does not + # end it, and stopping there would read `text: "prefix"` as the whole + # binding and exempt it as a literal while `+ externalValue` waits + # below — the exact misreading this function exists to prevent. + following = '' + for ahead in range(i + 1, len(lines)): + candidate = strip_noise(lines[ahead]) + if candidate.strip(): + following = candidate + break + continues = (parens > 0 or brackets > 0 + or TRAILING_OPERATOR.search(counted.rstrip()) + or LEADING_OPERATOR.match(following)) + if not continues: + break + i += 1 + + chunk = ' '.join(parts) + return chunk.split(':', 1)[1] if ':' in chunk else chunk + + +def exempt_as_literal(lines, tline): + """True when the binding is only string literals, however many lines.""" + return is_pure_literal(binding_expression(lines, tline)) + + +def blocks(lines): + stack = [] + done = [] + depth = 0 + for idx, raw in enumerate(lines): + code = strip_noise(raw) + opened = OPEN_ELEMENT.search(code) + prop = PROP.match(code) + if (prop and stack and stack[-1]['depth'] == depth + and not opened and not PROPERTY_DECL.match(code)): + stack[-1]['props'].setdefault(prop.group(1), idx) + n_open = code.count('{') + n_close = code.count('}') + depth += n_open - n_close + if opened and n_open > 0: + # OPEN_ELEMENT anchors at the end of the line, so the element it + # matched is the innermost one opened here and its depth is the + # depth after every brace on the line. + stack.append({'name': opened.group(1), 'depth': depth, + 'props': {}, 'start': idx}) + while stack and depth < stack[-1]['depth']: + done.append(stack.pop()) + done.extend(stack) + return done + + +INLINE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{([^{}]*)\}') +INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)') +# As a property of this block, not as a substring: `visible: root.textFormatEnabled` +# used to read as a declaration and exempt the element. +INLINE_TEXT_FORMAT = re.compile(r'(?:^|[;{\s])textFormat\s*:') + + +def inline_violations(lines, rel): + """Whole Text blocks written on one line. + + OPEN_ELEMENT anchors at the end of the line, so the brace scanner never + sees these. A Repeater delegate is a plausible place for one. + """ + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw, keep_strings=True) + for match in INLINE_TEXT.finditer(code): + body = match.group(1) + if INLINE_TEXT_FORMAT.search(body): + continue + # A component root written on one line needs the default whether or + # not this line binds `text`, for the same reason the block form + # does: every caller supplies the binding. + if INLINE_COMPONENT_ONELINE.match(code): + out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat') + continue + binding = INLINE_BINDING.search(body) + if not binding or is_pure_literal(binding.group(1)): + continue + out.append(f'{rel}:{idx + 1}: inline Text block without textFormat') + return out + + +# `Text { text: someValue` with the block carrying on below is valid QML and is +# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the +# line so the brace tracker never opens the block, and INLINE_TEXT needs the +# closing brace on the same line. A dynamic AutoText binding written that way +# passes this file in silence, which is the one failure a test like this must +# not have. +# +# Rather than teach a line scanner to parse QML, require the two forms it can +# read: the whole block on one line, or nothing after the opening brace. Every +# Text in this tree is already written that way, so keeping to it costs nothing. +UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*\{\s*\S') +BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])' + TEXT_NAME + r'\s*$') + +UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the ' + 'opening brace last on the line, or write the whole block on ' + 'one line with no nested braces') + + +COMPONENT_OPENER = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*$') + + +def opens_component(lines, start): + """True when the Text block at `start` is a component root declared above it.""" + for back in range(start - 1, -1, -1): + code = strip_noise(lines[back]).strip() + if not code: + continue + return bool(COMPONENT_OPENER.match(lines[back])) + return False + + +def unscannable_violations(lines, rel): + out = [] + for idx, raw in enumerate(lines): + code = strip_noise(raw) + + # `Text` with its brace on the next line. OPEN_ELEMENT needs both on + # one line, so the block is never opened and everything in it is + # attributed to the enclosing element instead. + if BARE_TEXT_OPENER.search(code): + following = '' + for ahead in range(idx + 1, len(lines)): + candidate = strip_noise(lines[ahead]).strip() + if candidate: + following = candidate + break + if following.startswith('{'): + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + continue + + for match in UNSCANNABLE_TEXT.finditer(code): + # A complete one-line block with no nested braces is fine — + # inline_violations reads those. Count rather than looking for a + # `}`, because `Text { text: ({ a: external }).a }` closes on this + # line yet INLINE_TEXT's brace-free body pattern cannot match it, + # so treating any `}` as "handled elsewhere" would drop it. + rest = code[match.end() - 1:] + depth = 1 + closed = False + for char in rest: + if char == '{': + depth += 1 + elif char == '}': + depth -= 1 + if depth == 0: + closed = True + break + if closed and '{' not in rest: + continue + out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') + return out + + +root = Path(sys.argv[1]) +found = [] +scanned = 0 + + +def unreadable(error): + # rglob() swallows a directory it cannot enter, so a shell/ subtree with no + # read permission scanned as though it were empty and the run reported + # success. Same failure as an empty tree, and it fails the same way. + raise SystemExit(f'cannot read {error.filename}: {error.strerror}') + + +qml = [] +for dirpath, dirnames, filenames in os.walk(root / 'shell', onerror=unreadable): + dirnames.sort() + qml.extend(Path(dirpath) / name for name in filenames if name.endswith('.qml')) + +for path in sorted(qml): + scanned += 1 + lines = strip_block_comments(path.read_text()).splitlines() + rel = path.relative_to(root) + found.extend(inline_violations(lines, rel)) + found.extend(unscannable_violations(lines, rel)) + + for b in blocks(lines): + if b['name'].split('.')[-1] != 'Text' or 'textFormat' in b['props']: + continue + + # Read the block's own properties. A nested child declaring textFormat + # says nothing about its parent, so `Text { Text { textFormat: ... } }` + # must still report the outer element. + # The root element of a component takes its binding from callers, so it + # needs the default whether or not this file binds `text`. Require both + # depth 1 and column 0: the scanner attributes one element per line, so + # a `Row { Text {` line would report depth 1 for a nested block, and + # falling through to the binding check below is the safe reading. + # Indentation is not what makes it a root; depth 1 is. A `Row { Text {` + # line still reads as `Row` here, so leading whitespace can be ignored + # without letting a nested block be mistaken for the file's root. + if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'): + found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') + continue + + # A QML inline component is a root for the same reason, and the rule + # above cannot see one: `component InfoValue: Text {` sits inside + # another element, so its depth is not 1 and its line does not start + # with `Text`. Its `text` comes from every caller, so the file it lives + # in never binds it and the binding check below lets it through in + # silence. Only one file-level root Text exists in this tree, so + # without this the root rule is very nearly dead code. + # `component Info:` may also put its `Text {` on the following line, + # which INLINE_COMPONENT cannot match and which then reads as an + # ordinary nested block with no binding of its own — a caller's dynamic + # text passing in silence. + if INLINE_COMPONENT.match(lines[b['start']]) or opens_component(lines, b['start']): + found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat') + continue + + if 'text' not in b['props']: + continue + tline = b['props']['text'] + if exempt_as_literal(lines, tline): + continue + found.append(f'{rel}:{tline + 1}: text binding without textFormat') + +# A scan that read nothing reports nothing, and an all-clear from a run that +# never opened a file is the one result this test must never give. Only a +# checkout with no shell/ QML at all reaches this. +if scanned == 0: + raise SystemExit('no .qml files found under shell/; the scan read nothing') + +for line in found: + print(line) diff --git a/test/shell.d/qml-text-format-test.sh b/test/shell.d/qml-text-format-test.sh index 0b286a76..4d987ce4 100755 --- a/test/shell.d/qml-text-format-test.sh +++ b/test/shell.d/qml-text-format-test.sh @@ -18,6 +18,11 @@ # bare string literal. A literal carries no external data, so AutoText has # nothing to promote; this test is what catches the edit that later turns such # a literal into an expression. +# +# The scan itself lives in qml-text-format-scan.py. It is run twice: over the +# real tree, and over the fixtures below, which are the forms that have already +# slipped past it once. A guard nothing can fail is a guard nobody should trust, +# and every one of those fixtures passed silently before it was written down. set -euo pipefail @@ -25,279 +30,9 @@ source "$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/base-test.sh" require_command python3 -violations=$(ROOT="$ROOT" python3 <<'PY' -import os -import re -from pathlib import Path +SCAN="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/qml-text-format-scan.py" -OPEN_ELEMENT = re.compile(r'(?:^|[:\s])([A-Z][A-Za-z0-9_.]*)\s*\{\s*$') -INLINE_COMPONENT = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{\s*$') -INLINE_COMPONENT_ONELINE = re.compile(r'^\s*component\s+[A-Za-z_][A-Za-z0-9_]*\s*:\s*Text\s*\{') -PROP = re.compile(r'^\s*([A-Za-z_][A-Za-z0-9_.]*)\s*:') -STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"|\'(?:[^\'\\]|\\.)*\'') -PROPERTY_DECL = re.compile(r'^\s*(?:readonly\s+)?property\b') -# A binding that runs onto the next line: this line ends on an operator, or the -# next line opens with one. -TRAILING_OPERATOR = re.compile(r'(?:&&|\|\||[?:+\-*/,(\[=&|])$') -LEADING_OPERATOR = re.compile(r'^\s*(?:&&|\|\||[?:+\-*/,)\]&|.])') - - -def strip_noise(line, keep_strings=False): - out = [] - i = 0 - quote = None - while i < len(line): - c = line[i] - if quote: - if keep_strings: - out.append(c) - if c == '\\': - if keep_strings and i + 1 < len(line): - out.append(line[i + 1]) - i += 2 - continue - if c == quote: - quote = None - if not keep_strings: - out.append('S') - i += 1 - continue - if c in '"\'': - quote = c - if keep_strings: - out.append(c) - i += 1 - continue - if c == '/' and i + 1 < len(line) and line[i + 1] == '/': - break - out.append(c) - i += 1 - return ''.join(out) - - -def is_pure_literal(expr): - residue = STRING_LITERAL.sub('', expr) - residue = re.sub(r'[\s+]', '', residue) - return residue == '' and STRING_LITERAL.search(expr) is not None - - -def binding_expression(lines, start): - """The whole right-hand side of the binding beginning on line `start`. - - The literal exemption has to be judged on the complete expression. Reading - only the physical `text:` line would exempt `text: "prefix"` while - `+ externalValue` sits underneath, letting a dynamic AutoText binding - through. Reading a wrapped concatenation of literals as dynamic would be - the opposite error, so follow the expression to its end either way. - """ - parts = [] - parens = brackets = 0 - i = start - while i < len(lines): - parts.append(strip_noise(lines[i], keep_strings=True)) - counted = strip_noise(lines[i]) - parens += counted.count('(') - counted.count(')') - brackets += counted.count('[') - counted.count(']') - # Look past blank and comment-only lines for the continuation. A - # comment or a blank line dropped into a wrapped expression does not - # end it, and stopping there would read `text: "prefix"` as the whole - # binding and exempt it as a literal while `+ externalValue` waits - # below — the exact misreading this function exists to prevent. - following = '' - for ahead in range(i + 1, len(lines)): - candidate = strip_noise(lines[ahead]) - if candidate.strip(): - following = candidate - break - continues = (parens > 0 or brackets > 0 - or TRAILING_OPERATOR.search(counted.rstrip()) - or LEADING_OPERATOR.match(following)) - if not continues: - break - i += 1 - - chunk = ' '.join(parts) - return chunk.split(':', 1)[1] if ':' in chunk else chunk - - -def exempt_as_literal(lines, tline): - """True when the binding is only string literals, however many lines.""" - return is_pure_literal(binding_expression(lines, tline)) - - -def blocks(lines): - stack = [] - done = [] - depth = 0 - for idx, raw in enumerate(lines): - code = strip_noise(raw) - opened = OPEN_ELEMENT.search(code) - prop = PROP.match(code) - if (prop and stack and stack[-1]['depth'] == depth - and not opened and not PROPERTY_DECL.match(code)): - stack[-1]['props'].setdefault(prop.group(1), idx) - n_open = code.count('{') - n_close = code.count('}') - depth += n_open - n_close - if opened and n_open > 0: - # OPEN_ELEMENT anchors at the end of the line, so the element it - # matched is the innermost one opened here and its depth is the - # depth after every brace on the line. - stack.append({'name': opened.group(1), 'depth': depth, - 'props': {}, 'start': idx}) - while stack and depth < stack[-1]['depth']: - done.append(stack.pop()) - done.extend(stack) - return done - - -INLINE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{([^{}]*)\}') -INLINE_BINDING = re.compile(r'\btext\s*:\s*(.*?)\s*(?:;|$)') - - -def inline_violations(lines, rel): - """Whole Text blocks written on one line. - - OPEN_ELEMENT anchors at the end of the line, so the brace scanner never - sees these. A Repeater delegate is a plausible place for one. - """ - out = [] - for idx, raw in enumerate(lines): - code = strip_noise(raw, keep_strings=True) - for match in INLINE_TEXT.finditer(code): - body = match.group(1) - if 'textFormat' in body: - continue - # A component root written on one line needs the default whether or - # not this line binds `text`, for the same reason the block form - # does: every caller supplies the binding. - if INLINE_COMPONENT_ONELINE.match(code): - out.append(f'{rel}:{idx + 1}: inline component root Text declares no textFormat') - continue - binding = INLINE_BINDING.search(body) - if not binding or is_pure_literal(binding.group(1)): - continue - out.append(f'{rel}:{idx + 1}: inline Text block without textFormat') - return out - - -# `Text { text: someValue` with the block carrying on below is valid QML and is -# invisible to both scanners: OPEN_ELEMENT anchors its `{` at the end of the -# line so the brace tracker never opens the block, and INLINE_TEXT needs the -# closing brace on the same line. A dynamic AutoText binding written that way -# passes this file in silence, which is the one failure a test like this must -# not have. -# -# Rather than teach a line scanner to parse QML, require the two forms it can -# read: the whole block on one line, or nothing after the opening brace. Every -# Text in this tree is already written that way, so keeping to it costs nothing. -UNSCANNABLE_TEXT = re.compile(r'(?:^|[:\s])Text\s*\{\s*\S') -BARE_TEXT_OPENER = re.compile(r'(?:^|[:\s])Text\s*$') - -UNSCANNABLE = ('Text block written in a form this scanner cannot read; put the ' - 'opening brace last on the line, or write the whole block on ' - 'one line with no nested braces') - - -def unscannable_violations(lines, rel): - out = [] - for idx, raw in enumerate(lines): - code = strip_noise(raw) - - # `Text` with its brace on the next line. OPEN_ELEMENT needs both on - # one line, so the block is never opened and everything in it is - # attributed to the enclosing element instead. - if BARE_TEXT_OPENER.search(code): - following = '' - for ahead in range(idx + 1, len(lines)): - candidate = strip_noise(lines[ahead]).strip() - if candidate: - following = candidate - break - if following.startswith('{'): - out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') - continue - - for match in UNSCANNABLE_TEXT.finditer(code): - # A complete one-line block with no nested braces is fine — - # inline_violations reads those. Count rather than looking for a - # `}`, because `Text { text: ({ a: external }).a }` closes on this - # line yet INLINE_TEXT's brace-free body pattern cannot match it, - # so treating any `}` as "handled elsewhere" would drop it. - rest = code[match.end() - 1:] - depth = 1 - closed = False - for char in rest: - if char == '{': - depth += 1 - elif char == '}': - depth -= 1 - if depth == 0: - closed = True - break - if closed and '{' not in rest: - continue - out.append(f'{rel}:{idx + 1}: {UNSCANNABLE}') - return out - - -root = Path(os.environ['ROOT']) -found = [] -scanned = 0 -for path in sorted((root / 'shell').rglob('*.qml')): - scanned += 1 - lines = path.read_text().splitlines() - rel = path.relative_to(root) - found.extend(inline_violations(lines, rel)) - found.extend(unscannable_violations(lines, rel)) - - for b in blocks(lines): - if b['name'] != 'Text' or 'textFormat' in b['props']: - continue - - # Read the block's own properties. A nested child declaring textFormat - # says nothing about its parent, so `Text { Text { textFormat: ... } }` - # must still report the outer element. - # The root element of a component takes its binding from callers, so it - # needs the default whether or not this file binds `text`. Require both - # depth 1 and column 0: the scanner attributes one element per line, so - # a `Row { Text {` line would report depth 1 for a nested block, and - # falling through to the binding check below is the safe reading. - # Indentation is not what makes it a root; depth 1 is. A `Row { Text {` - # line still reads as `Row` here, so leading whitespace can be ignored - # without letting a nested block be mistaken for the file's root. - if b['depth'] == 1 and lines[b['start']].lstrip().startswith('Text'): - found.append(f'{rel}:{b["start"] + 1}: root Text element declares no textFormat') - continue - - # A QML inline component is a root for the same reason, and the rule - # above cannot see one: `component InfoValue: Text {` sits inside - # another element, so its depth is not 1 and its line does not start - # with `Text`. Its `text` comes from every caller, so the file it lives - # in never binds it and the binding check below lets it through in - # silence. Only one file-level root Text exists in this tree, so - # without this the root rule is very nearly dead code. - if INLINE_COMPONENT.match(lines[b['start']]): - found.append(f'{rel}:{b["start"] + 1}: inline component root Text declares no textFormat') - continue - - if 'text' not in b['props']: - continue - tline = b['props']['text'] - if exempt_as_literal(lines, tline): - continue - found.append(f'{rel}:{tline + 1}: text binding without textFormat') - -# A scan that read nothing reports nothing, and an all-clear from a run that -# never opened a file is the one result this test must never give. Only a -# checkout with no shell/ QML at all reaches this. -if scanned == 0: - raise SystemExit('no .qml files found under shell/; the scan read nothing') - -for line in found: - print(line) -PY -) +violations=$(python3 "$SCAN" "$ROOT") if [[ -n $violations ]]; then count=$(printf '%s\n' "$violations" | wc -l) @@ -311,3 +46,231 @@ deliberate, documented feature, and strip before it reaches the renderer." fi pass "every Text with a dynamic text binding declares textFormat" + +# The scanner's own tests. Each fixture is a Text that renders external data +# with no textFormat, written in a form that once passed. `caught` asserts the +# scan reports something; `clean` asserts it does not, so the fixtures prove the +# scanner can fail rather than that it fails at everything. +fixture_root=$(mktemp -d) +trap 'chmod -R u+rwX "$fixture_root" 2>/dev/null; rm -rf "$fixture_root"' EXIT + +function scan_fixture { + local name=$1 + local dir="$fixture_root/$name" + mkdir -p "$dir/shell/Ui" + cat > "$dir/shell/Ui/Fixture.qml" + python3 "$SCAN" "$dir" 2>&1 +} + +function caught { + local name=$1 description=$2 output + output=$(scan_fixture "$name" || true) + if [[ -z $output ]]; then + fail "$description" "the scan reported nothing for fixture $name" + fi + pass "$description" +} + +function clean { + local name=$1 description=$2 output + output=$(scan_fixture "$name" || true) + if [[ -n $output ]]; then + fail "$description" "the scan reported: $output" + fi + pass "$description" +} + +caught plain "the scan reports a plain dynamic binding with no textFormat" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + text: external + } +} +QML + +clean literal "the scan leaves a string literal alone" <<'QML' +import QtQuick +Item { + Text { + text: "a literal" + } +} +QML + +clean declared "the scan leaves a declared textFormat alone" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + textFormat: Text.PlainText + text: external + } +} +QML + +# strip_noise() knew `//` and not `/* */`, so a block comment between the type +# name and its brace hid the whole element from every rule. +caught block-comment "the scan reads a Text whose brace a block comment hides" <<'QML' +import QtQuick +Item { + property string external: "x" + Text /* explanation */ { + text: external + } +} +QML + +caught block-comment-multiline "the scan reads past a block comment spanning lines" <<'QML' +import QtQuick +Item { + property string external: "x" + /* + * Text { text: "not this one" } + */ + Text { + text: external + } +} +QML + +# `import QtQuick as QQ` makes the element `QQ.Text`, which compared unequal to +# `Text` and was skipped outright. +caught namespaced "the scan reads a Text reached through a namespaced import" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { + text: external + } +} +QML + +# textFormat was matched as a substring, so any property whose name merely +# started that way exempted the element. +caught namespaced-inline "the scan reads a one-line namespaced Text block" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { text: external } +} +QML + +caught namespaced-unscannable "the scan rejects an unreadable namespaced Text block" <<'QML' +import QtQuick as QQ +QQ.Item { + property string external: "x" + QQ.Text { text: external + color: "red" + } +} +QML + +caught textformat-substring "the scan does not accept a lookalike property as textFormat" <<'QML' +import QtQuick +Item { + property string external: "x" + property bool textFormatEnabled: true + Text { text: external; visible: textFormatEnabled } +} +QML + +# A component root takes its text from every caller, so the file it lives in +# never binds it. The one-line form was covered; this one was not. +caught component-next-line "the scan reads a component root whose Text sits on the next line" <<'QML' +import QtQuick +Item { + component Info: + Text { + } +} +QML + +caught component-one-line "the scan reads a component root written on one line" <<'QML' +import QtQuick +Item { + component Info: Text { color: "red" } +} +QML + +# Forms the scanner cannot read are reported rather than passed, which is the +# whole reason it can be a line scanner at all. +caught brace-next-line "the scan rejects a Text whose opening brace is on the next line" <<'QML' +import QtQuick +Item { + property string external: "x" + Text + { + text: external + } +} +QML + +caught trailing-binding "the scan rejects a Text with a binding after the opening brace" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { text: external + color: "red" + } +} +QML + +# A wrapped binding is judged whole: a literal first line says nothing about +# what is concatenated onto it below. +caught wrapped-binding "the scan follows a wrapped binding past its literal first line" <<'QML' +import QtQuick +Item { + property string external: "x" + Text { + text: "prefix" + + external + } +} +QML + +clean wrapped-literals "the scan leaves a wrapped concatenation of literals alone" <<'QML' +import QtQuick +Item { + Text { + text: "one" + + "two" + } +} +QML + +# A nested child's textFormat says nothing about its parent. +caught nested-child "the scan does not let a nested child's textFormat cover its parent" <<'QML' +import QtQuick +Text { + text: external.value + Text { + textFormat: Text.PlainText + text: "literal" + } +} +QML + +# A scan that reads less than the tree holds must not report success. Both of +# these once did. +empty_root=$(mktemp -d) +mkdir -p "$empty_root/shell" +if python3 "$SCAN" "$empty_root" > /dev/null 2>&1; then + rm -rf "$empty_root" + fail "the scan fails when it reads no files" "an empty shell/ tree exited 0" +fi +rm -rf "$empty_root" +pass "the scan fails when it reads no files" + +blind_root="$fixture_root/blind" +mkdir -p "$blind_root/shell/Ui/locked" +printf 'import QtQuick\nItem {\n Text {\n textFormat: Text.PlainText\n text: "ok"\n }\n}\n' > "$blind_root/shell/Ui/Good.qml" +printf 'import QtQuick\nItem {\n property string external: "x"\n Text {\n text: external\n }\n}\n' > "$blind_root/shell/Ui/locked/Bad.qml" +chmod 000 "$blind_root/shell/Ui/locked" +if python3 "$SCAN" "$blind_root" > /dev/null 2>&1; then + chmod 755 "$blind_root/shell/Ui/locked" + fail "the scan fails when a directory hides files from it" "an unreadable subdirectory exited 0" +fi +chmod 755 "$blind_root/shell/Ui/locked" +pass "the scan fails when a directory hides files from it"