1#!/usr/bin/env python 2 3# Copyright (C) 2018 The Android Open Source Project 4# 5# Licensed under the Apache License, Version 2.0 (the 'License'); 6# you may not use this file except in compliance with the License. 7# You may obtain a copy of the License at 8# 9# http://www.apache.org/licenses/LICENSE-2.0 10# 11# Unless required by applicable law or agreed to in writing, software 12# distributed under the License is distributed on an 'AS IS' BASIS, 13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14# See the License for the specific language governing permissions and 15# limitations under the License. 16 17""" 18Enforces common Android string best-practices. It ignores lint messages from 19a previous strings file, if provided. 20 21Usage: stringslint.py strings.xml 22Usage: stringslint.py strings.xml old_strings.xml 23""" 24 25import re, sys 26import lxml.etree as ET 27 28BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8) 29 30def format(fg=None, bg=None, bright=False, bold=False, dim=False, reset=False): 31 # manually derived from http://en.wikipedia.org/wiki/ANSI_escape_code#Codes 32 codes = [] 33 if reset: codes.append("0") 34 else: 35 if not fg is None: codes.append("3%d" % (fg)) 36 if not bg is None: 37 if not bright: codes.append("4%d" % (bg)) 38 else: codes.append("10%d" % (bg)) 39 if bold: codes.append("1") 40 elif dim: codes.append("2") 41 else: codes.append("22") 42 return "\033[%sm" % (";".join(codes)) 43 44warnings = None 45 46def warn(tag, msg, actual, expected): 47 global warnings 48 key = "%s:%d" % (tag.attrib["name"], hash(msg)) 49 value = "%sLine %d: '%s':%s %s" % (format(fg=YELLOW, bold=True), 50 tag.sourceline, 51 tag.attrib["name"], 52 format(reset=True), 53 msg) 54 if not actual is None: value += "\n\tActual: %s%s%s" % (format(dim=True), 55 actual, 56 format(reset=True)) 57 if not expected is None: value += "\n\tExample: %s%s%s" % (format(dim=True), 58 expected, 59 format(reset=True)) 60 warnings[key] = value 61 62def lint(path): 63 global warnings 64 warnings = {} 65 66 with open(path) as f: 67 raw = f.read() 68 if len(raw.strip()) == 0: 69 return warnings 70 tree = ET.fromstring(raw) 71 root = tree #tree.getroot() 72 73 last_comment = None 74 for child in root: 75 # TODO: handle plurals 76 if isinstance(child, ET._Comment): 77 last_comment = child 78 elif child.tag == "string": 79 # We always consume comment 80 comment = last_comment 81 last_comment = None 82 83 # Validate comment 84 if comment is None: 85 warn(child, "Missing string comment to aid translation", 86 None, None) 87 continue 88 if "do not translate" in comment.text.lower(): 89 continue 90 if "translatable" in child.attrib and child.attrib["translatable"].lower() == "false": 91 continue 92 if re.search("CHAR[ _-]LIMIT=(\d+|NONE|none)", comment.text) is None: 93 warn(child, "Missing CHAR LIMIT to aid translation", 94 repr(comment), "<!-- Description of string [CHAR LIMIT=32] -->") 95 96 # Look for common mistakes/substitutions 97 text = "".join(child.itertext()).strip() 98 if "'" in text: 99 warn(child, "Turned quotation mark glyphs are more polished", 100 text, "This doesn\u2019t need to \u2018happen\u2019 today") 101 if '"' in text and not text.startswith('"') and text.endswith('"'): 102 warn(child, "Turned quotation mark glyphs are more polished", 103 text, "This needs to \u201chappen\u201d today") 104 if "..." in text: 105 warn(child, "Ellipsis glyph is more polished", 106 text, "Loading\u2026") 107 if "wi-fi" in text.lower(): 108 warn(child, "Non-breaking glyph is more polished", 109 text, "Wi\u2011Fi") 110 if "wifi" in text.lower(): 111 warn(child, "Using non-standard spelling", 112 text, "Wi\u2011Fi") 113 if re.search("\d-\d", text): 114 warn(child, "Ranges should use en dash glyph", 115 text, "You will find this material in chapters 8\u201312") 116 if "--" in text: 117 warn(child, "Phrases should use em dash glyph", 118 text, "Upon discovering errors\u2014all 124 of them\u2014they recalled.") 119 if ". " in text: 120 warn(child, "Only use single space between sentences", 121 text, "First idea. Second idea.") 122 123 # When more than one substitution, require indexes 124 if len(re.findall("%[^%]", text)) > 1: 125 if len(re.findall("%[^\d]", text)) > 0: 126 warn(child, "Substitutions must be indexed", 127 text, "Add %1$s to %2$s") 128 129 # Require xliff substitutions 130 for gc in child.iter(): 131 badsub = False 132 if gc.tail and re.search("%[^%]", gc.tail): badsub = True 133 if re.match("{.*xliff.*}g", gc.tag): 134 if "id" not in gc.attrib: 135 warn(child, "Substitutions must define id attribute", 136 None, "<xliff:g id=\"domain\" example=\"example.com\">%1$s</xliff:g>") 137 if "example" not in gc.attrib: 138 warn(child, "Substitutions must define example attribute", 139 None, "<xliff:g id=\"domain\" example=\"example.com\">%1$s</xliff:g>") 140 else: 141 if gc.text and re.search("%[^%]", gc.text): badsub = True 142 if badsub: 143 warn(child, "Substitutions must be inside xliff tags", 144 text, "<xliff:g id=\"domain\" example=\"example.com\">%1$s</xliff:g>") 145 146 return warnings 147 148if len(sys.argv) > 2: 149 before = lint(sys.argv[2]) 150else: 151 before = {} 152after = lint(sys.argv[1]) 153 154for b in before: 155 if b in after: 156 del after[b] 157 158if len(after) > 0: 159 for a in sorted(after.keys()): 160 print after[a] 161 print 162 sys.exit(1) 163