Add check-strings (#6379)
This adds a script `check-strings` that checks whether strings, such as file paths or class names, are valid in files outside of the source code, e.g. in translations or themes. This also adds a verify script to verify `check-strings` on a constant git commit. Both scripts are under CI.
This commit is contained in:
15
tests/check-strings/README.md
Normal file
15
tests/check-strings/README.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Check strings
|
||||
|
||||
This tool checks for invalid strings not necessarily in the code.
|
||||
|
||||
Background: When you move a lot of classes and files, the code may be OK, but
|
||||
many strings outside the code (translations, style.css, .gitmodules etc.) may
|
||||
still need to be updated. This script checks whether these strings are still
|
||||
matching the code.
|
||||
|
||||
Content:
|
||||
|
||||
* `check-strings`: Script to check for invalid strings
|
||||
* `verify`: Script verifying `check-strings`
|
||||
* expectation.txt: Expectation file for verify script
|
||||
|
||||
160
tests/check-strings/check-strings
Executable file
160
tests/check-strings/check-strings
Executable file
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
# This script checks for strings like paths or class names that are *not* in the source code, but e.g. in
|
||||
# translation files, stylesheets or git files.
|
||||
# Invalid strings *in the source code* are usually recognized when you compile them, but other strings may
|
||||
# be overseen, which is why this script checks strings *outside of the source code*.
|
||||
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
import xml.etree.ElementTree as ElementTree
|
||||
from pathlib import Path
|
||||
import tinycss2
|
||||
|
||||
|
||||
# global variables
|
||||
|
||||
errors = 0
|
||||
|
||||
|
||||
# functions
|
||||
|
||||
def caption(my_str):
|
||||
print(f'\n# {my_str}\n')
|
||||
|
||||
def error(where, my_str):
|
||||
global errors
|
||||
errors += 1
|
||||
print(f'Error: {where}: {my_str}')
|
||||
|
||||
# if a string makes a classname, we need to check if that class is in the source
|
||||
# however, some of these strings name classes that are not from LMMS, so these can be ignored for such checks:
|
||||
def is_our_class(classname: str) -> int:
|
||||
return classname[0] != 'Q' # Qt classes
|
||||
|
||||
|
||||
# prepare some variables
|
||||
|
||||
if not Path('.gitmodules').is_file():
|
||||
print('You need to call this script from the LMMS top directory')
|
||||
exit(1)
|
||||
|
||||
result = subprocess.run(['git', 'ls-files', '*.[ch]', '*.[ch]pp', '*.ui', ':!tests/*'],
|
||||
capture_output=True, text=True, check=True)
|
||||
files = [Path(f) for f in result.stdout.splitlines()]
|
||||
carlabase = 'carlabase' if Path('plugins/carlabase').is_dir() else 'CarlaBase'
|
||||
carlapath = f'plugins/{carlabase}/carla/'
|
||||
result = subprocess.run(['git',
|
||||
'--git-dir', f'{carlapath}/.git',
|
||||
'--work-tree', f'{carlapath}',
|
||||
'ls-files', 'resources/ui', 'source/frontend'], capture_output=True, text=True, check=True)
|
||||
files.extend([Path(f'{carlapath}/{f}') for f in result.stdout.splitlines()])
|
||||
|
||||
classes = set()
|
||||
class_pat = re.compile(r'^\s*class(?:\s+LMMS_EXPORT)?\s+([a-zA-Z_][a-zA-Z0-9_]*)', re.MULTILINE)
|
||||
class_pat_ui = re.compile(r'<class>([a-zA-Z_][a-zA-Z0-9_]*)</class>')
|
||||
for cur_file in files:
|
||||
if cur_file.is_file():
|
||||
text = cur_file.read_text(errors='replace')
|
||||
classes.update(re.findall(class_pat_ui if cur_file.suffix == '.ui' else class_pat, text))
|
||||
|
||||
|
||||
# the real checks
|
||||
|
||||
caption('.gitmodules')
|
||||
|
||||
for p in re.findall(r'\[submodule "([^"]+)"\]\s*$', Path('.gitmodules').read_text(errors='replace'), re.MULTILINE):
|
||||
if not Path(p).is_dir():
|
||||
error('.gitmodules', f'Directory does not exist: {p}')
|
||||
|
||||
|
||||
caption('locale')
|
||||
|
||||
filenames = set()
|
||||
classes_found = set()
|
||||
for cur_file in Path('data/locale').glob('*.ts'):
|
||||
tree = ElementTree.parse(str(cur_file))
|
||||
root = tree.getroot()
|
||||
for location in root.findall('./context/message/location'):
|
||||
filenames.add(location.attrib['filename'])
|
||||
for location in root.findall('./context/name'):
|
||||
classes_found.add(location.text)
|
||||
for f in sorted(filenames):
|
||||
# The files sometimes are relative to data/local and sometimes to the git tree's root...
|
||||
if not Path(f).is_file() and not Path(f'data/locale/{f}').is_file():
|
||||
error('data/locale', f'Source file does not exist: {f}')
|
||||
for c in sorted(classes_found):
|
||||
if is_our_class(c) and '::' not in c and c not in classes:
|
||||
error('data/locale', f'Class does not exist in source code: {c}')
|
||||
|
||||
|
||||
caption('themes')
|
||||
|
||||
for theme in sorted([d for d in Path('data/themes').iterdir() if d.is_dir()]):
|
||||
classes_in_sheet = set()
|
||||
stylesheet = theme / 'style.css'
|
||||
rules = tinycss2.parse_stylesheet(Path(stylesheet).read_text(errors='replace'))
|
||||
for rule in rules:
|
||||
if rule.type == 'qualified-rule':
|
||||
class_found = False
|
||||
for c in rule.prelude:
|
||||
if c.type == 'ident' and not class_found:
|
||||
if is_our_class(c.value):
|
||||
classes_in_sheet.add(c.value)
|
||||
class_found = True
|
||||
# After whitespace or comma comes a new class
|
||||
elif c.type == 'whitespace' or (c.type == 'literal' and c.value == ','):
|
||||
class_found = False
|
||||
missing_classes = classes_in_sheet - classes
|
||||
for class_in_sheet in sorted(missing_classes):
|
||||
error(str(stylesheet), f'Class does not exist in source code: {class_in_sheet}')
|
||||
|
||||
|
||||
caption('patches (checks only plugins/)')
|
||||
|
||||
pat = re.compile(r'/(plugins/\S*)', re.MULTILINE)
|
||||
calf = re.compile(r'calf/.*/modules\.') # these are a bit complicated to fix...
|
||||
for cur_file in sorted(Path('.').glob('*/patches/*.patch')):
|
||||
if Path(cur_file).is_file():
|
||||
paths_in_patches = set()
|
||||
for line in pat.findall(cur_file.read_text(errors='replace')):
|
||||
if not calf.search(line):
|
||||
paths_in_patches.add(Path(line))
|
||||
for mpath in sorted(paths_in_patches):
|
||||
# in case of LADSPA SWH effects, check that the XML exists, not the C file
|
||||
# (because the C files are not generated until a build is done)
|
||||
if mpath.parent == Path('plugins/LadspaEffect/swh/ladspa/'):
|
||||
mpath = mpath.with_suffix('.xml')
|
||||
if not mpath.is_file():
|
||||
error(str(cur_file), f'Source file does not exist: {str(mpath)}')
|
||||
|
||||
|
||||
caption('debian docs (only one string)')
|
||||
|
||||
# Checks for caps.html. This gets relevant when #4027 will be merged
|
||||
for line in Path('debian/lmms-common.docs').read_text(errors='replace').splitlines():
|
||||
line = line.rstrip()
|
||||
if 'caps.html' in line and not Path(line).is_file():
|
||||
error('debian/lmms-common.docs', f'Path does not exist: {line}')
|
||||
|
||||
|
||||
caption('debian/copyright')
|
||||
|
||||
pat = re.compile(r'^Files:\s*(\S+).*$', re.MULTILINE)
|
||||
ladspa_swh = re.compile(r'(plugins/LadspaEffect/swh/ladspa/[^/.]+)\.c')
|
||||
for mpath in pat.findall(Path('debian/copyright').read_text(errors='replace')):
|
||||
# in case of LADSPA SWH effects, check that the XML exists, not the C file
|
||||
# (because the C files are not generated until a build is done)
|
||||
if res2 := ladspa_swh.match(mpath):
|
||||
mpath = res2.group(1) + '.xml'
|
||||
if not any(Path('.').glob(mpath)):
|
||||
error('debian/copyright', f'Glob/Path does not exist: {mpath}')
|
||||
|
||||
|
||||
# summary
|
||||
|
||||
caption('summary')
|
||||
|
||||
print(f'{str(errors)} errors.')
|
||||
exit(1 if errors > 0 else 0)
|
||||
81
tests/check-strings/expectation.txt
Normal file
81
tests/check-strings/expectation.txt
Normal file
@@ -0,0 +1,81 @@
|
||||
|
||||
# .gitmodules
|
||||
|
||||
Error: .gitmodules: Directory does not exist: plugins/sid/resid
|
||||
|
||||
# locale
|
||||
|
||||
Error: data/locale: Source file does not exist: ../../src/gui/BBClipView.cpp
|
||||
Error: data/locale: Source file does not exist: ../../src/gui/editors/BBEditor.cpp
|
||||
Error: data/locale: Source file does not exist: ../../src/tracks/BBTrack.cpp
|
||||
Error: data/locale: Source file does not exist: plugins/SpectrumAnalyzer/SpectrumAnalyzer.cpp
|
||||
Error: data/locale: Source file does not exist: plugins/SpectrumAnalyzer/SpectrumAnalyzerControlDialog.cpp
|
||||
Error: data/locale: Source file does not exist: plugins/SpectrumAnalyzer/SpectrumAnalyzerControls.cpp
|
||||
Error: data/locale: Source file does not exist: plugins/flp_import/FlpImport.cpp
|
||||
Error: data/locale: Source file does not exist: plugins/opl2/opl2instrument.cpp
|
||||
Error: data/locale: Source file does not exist: plugins/papu/papu_instrument.cpp
|
||||
Error: data/locale: Source file does not exist: plugins/sid/sid_instrument.cpp
|
||||
Error: data/locale: Source file does not exist: src/gui/editors/BBEditor.cpp
|
||||
Error: data/locale: Source file does not exist: src/gui/widgets/VisualizationWidget.cpp
|
||||
Error: data/locale: Source file does not exist: src/tracks/BBTrack.cpp
|
||||
Error: data/locale: Class does not exist in source code: BBClipView
|
||||
Error: data/locale: Class does not exist in source code: BBEditor
|
||||
Error: data/locale: Class does not exist in source code: BBTrack
|
||||
Error: data/locale: Class does not exist in source code: SpectrumAnalyzerControlDialog
|
||||
Error: data/locale: Class does not exist in source code: SpectrumAnalyzerControls
|
||||
Error: data/locale: Class does not exist in source code: VisualizationWidget
|
||||
Error: data/locale: Class does not exist in source code: mixerLineLcdSpinBox
|
||||
Error: data/locale: Class does not exist in source code: opl2instrument
|
||||
Error: data/locale: Class does not exist in source code: opl2instrumentView
|
||||
Error: data/locale: Class does not exist in source code: papuInstrument
|
||||
Error: data/locale: Class does not exist in source code: papuInstrumentView
|
||||
Error: data/locale: Class does not exist in source code: pluginBrowser
|
||||
Error: data/locale: Class does not exist in source code: sidInstrument
|
||||
Error: data/locale: Class does not exist in source code: sidInstrumentView
|
||||
|
||||
# themes
|
||||
|
||||
Error: data/themes/classic/style.css: Class does not exist in source code: PluginDescList
|
||||
Error: data/themes/classic/style.css: Class does not exist in source code: effectLabel
|
||||
Error: data/themes/classic/style.css: Class does not exist in source code: nameLabel
|
||||
Error: data/themes/classic/style.css: Class does not exist in source code: opl2instrumentView
|
||||
Error: data/themes/classic/style.css: Class does not exist in source code: sidInstrumentView
|
||||
Error: data/themes/default/style.css: Class does not exist in source code: PluginDescList
|
||||
Error: data/themes/default/style.css: Class does not exist in source code: effectLabel
|
||||
Error: data/themes/default/style.css: Class does not exist in source code: nameLabel
|
||||
Error: data/themes/default/style.css: Class does not exist in source code: opl2instrumentView
|
||||
Error: data/themes/default/style.css: Class does not exist in source code: sidInstrumentView
|
||||
|
||||
# patches (checks only plugins/)
|
||||
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/calf/src/calf/metadata.h
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/calf/src/calf/modules_comp.h
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/calf/src/calf/modules_limit.h
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/calf/src/calf/modules_mod.h
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/calf/src/calf/organ.h
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/calf/src/calf/preset.h
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/calf/src/calf/primitives.h
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/calf/src/metadata.cpp
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/swh/flanger_1191.c
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/swh/gsm/short_term.c
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/swh/multivoice_chorus_1201.c
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/swh/retro_flange_1208.c
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/LadspaEffect/swh/vynil_1905.c
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/delay/stereodelay.cpp
|
||||
Error: debian/patches/clang.patch: Source file does not exist: plugins/opl2/fmopl.c
|
||||
|
||||
# debian docs (only one string)
|
||||
|
||||
|
||||
# debian/copyright
|
||||
|
||||
Error: debian/copyright: Glob/Path does not exist: data/projects/CoolSongs/Saber-*
|
||||
Error: debian/copyright: Glob/Path does not exist: plugins/LadspaEffect/calf/src/calf/vumeter.h
|
||||
Error: debian/copyright: Glob/Path does not exist: plugins/LadspaEffect/swh/gsm/*
|
||||
Error: debian/copyright: Glob/Path does not exist: plugins/LadspaEffect/swh/util/pitchscale.c
|
||||
Error: debian/copyright: Glob/Path does not exist: plugins/LadspaEffect/swh/vocoder_1337.c
|
||||
Error: debian/copyright: Glob/Path does not exist: plugins/opl2/fmopl.*
|
||||
|
||||
# summary
|
||||
|
||||
59 errors.
|
||||
60
tests/check-strings/verify
Executable file
60
tests/check-strings/verify
Executable file
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
lmms_main_path = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
res = subprocess.run(['git', 'rev-parse', 'HEAD'], capture_output=True, text=True, check=True)
|
||||
head = res.stdout.rstrip()
|
||||
|
||||
# test with a stable LMMS ref to always get the same expectations
|
||||
ref = 'f56fc68b669b59f525ad133a0376feaba2a1a4ec'
|
||||
|
||||
# find out a remote URL where we can fetch "ref" from
|
||||
res = subprocess.run(['git', 'rev-parse', '-q', '--verify', ref+'^{commit}'], capture_output=True, text=True)
|
||||
if res.returncode == 0:
|
||||
remote = str(lmms_main_path)
|
||||
else:
|
||||
res = subprocess.run(['git', 'remote', 'get-url', 'origin'], capture_output=True, text=True)
|
||||
if res.returncode == 0:
|
||||
remote = res.stdout.rstrip()
|
||||
else:
|
||||
res = subprocess.run(['git', 'remote', 'get-url', 'upstream'], capture_output=True, text=True)
|
||||
if res.returncode == 0:
|
||||
remote = res.stdout.rstrip()
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Sorry, I can find ref " + ref + " neither in the repo nor in the remotes \"origin\" and \"upstream\"")
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
os.chdir(tmpdir)
|
||||
# get the repo and its submodules
|
||||
subprocess.run(['git', 'init'], check=True)
|
||||
subprocess.run(['git', 'remote', 'add', 'origin', remote], check=True)
|
||||
subprocess.run(['git', 'fetch', '--depth=1', 'origin', ref, head], check=True)
|
||||
subprocess.run(['git', 'checkout', ref], check=True)
|
||||
subprocess.run(['git', 'submodule', 'update', '--depth=1', '--init'], check=True)
|
||||
# checkout the CURRENT check-strings, because it is subject-under-test
|
||||
subprocess.run(['git', 'checkout', head, 'tests/check-strings/check-strings'], check=True)
|
||||
# run script
|
||||
res = subprocess.run([str(lmms_main_path / 'tests' / 'check-strings' / 'check-strings')],
|
||||
capture_output=True, text=True)
|
||||
print('--->8--- Script output BEGIN --->8---')
|
||||
print(res.stdout)
|
||||
print('--->8--- Script output END --->8---')
|
||||
if res.stderr:
|
||||
print('--->8--- Script error output BEGIN --->8---')
|
||||
print(res.stderr)
|
||||
print('--->8--- Script error output END --->8---')
|
||||
# make sure script returned "error" (because we test for errors) and that the output is as expected
|
||||
if res.returncode == 0:
|
||||
raise RuntimeError("Script \"check-strings\" no errors, but errors were expected")
|
||||
else:
|
||||
if res.stdout != open(str(lmms_main_path / 'tests' / 'check-strings' / 'expectation.txt')).read():
|
||||
raise RuntimeError("Script \"check-strings\" returned with different output than in expectation.txt")
|
||||
|
||||
# if we made it until here without an exception, all tests have been passed
|
||||
print("SUCCESS")
|
||||
Reference in New Issue
Block a user