OmaPhoto 1.0.0: the Linux port of Compositor
A layered image editor for Linux and Omarchy, ported from Compositor (github.com/robbietilton/Compositor), whose Swift source is its specification. It reads and writes Compositor's .comp projects. Packages for Ubuntu, Fedora and Arch come from the release workflow; scripts/install/ holds one install script per distribution family, and flake.nix serves NixOS. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
commit
c864304695
564 files changed
+100804
No files matched your search
Executable
+40
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
repo=$(cd "$(dirname "$0")/.." && pwd)
|
||||
image=omaphoto-dev
|
||||
runtime=/run/user/$(id -u)
|
||||
|
||||
docker build -q -t "$image" -f "$repo/Dockerfile.dev" "$repo" >/dev/null
|
||||
|
||||
in_container() {
|
||||
exec docker run --rm --init -u "$(id -u):$(id -g)" -e HOME=/tmp --tmpfs /tmp:rw,exec,size=4g \
|
||||
--tmpfs "$runtime:uid=$(id -u),gid=$(id -g),mode=0700" -e XDG_RUNTIME_DIR="$runtime" \
|
||||
-v "$repo:$repo" -w "$repo" "$@"
|
||||
}
|
||||
|
||||
configure_and_build='cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON && cmake --build build'
|
||||
|
||||
case "${1:-}" in
|
||||
build)
|
||||
in_container "$image" sh -c "$configure_and_build"
|
||||
;;
|
||||
test)
|
||||
in_container "$image" sh -c "$configure_and_build && ctest --test-dir build --output-on-failure --timeout 120"
|
||||
;;
|
||||
run)
|
||||
socket=$WAYLAND_DISPLAY
|
||||
[[ $socket = /* ]] || socket=$XDG_RUNTIME_DIR/$socket
|
||||
socket=$(readlink -f "$socket")
|
||||
# The desktop's theme, where Omarchy keeps it, read-only.
|
||||
theme=$HOME/.local/state/omarchy
|
||||
mounts=()
|
||||
[[ -d $theme ]] && mounts=(-v "$theme:/tmp/.local/state/omarchy:ro")
|
||||
in_container "${mounts[@]}" -v "$socket:$runtime/wayland-0" -e WAYLAND_DISPLAY=wayland-0 \
|
||||
-e QT_QPA_PLATFORM=wayland "$image" build/omaphoto "${@:2}"
|
||||
;;
|
||||
*)
|
||||
echo "usage: dev.sh build|test|run [args]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds and tests the app in one distribution's own container.
|
||||
set -euo pipefail
|
||||
distro=${1:?"usage: distro-check.sh arch|fedora|nixos"}
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
repo=$(cd "$here/.." && pwd)
|
||||
docker build -t "omaphoto-$distro" "$here/distro/$distro"
|
||||
steps="cmake -S . -B /tmp/build -G Ninja -DOMAPHOTO_WERROR=ON && cmake --build /tmp/build && ctest --test-dir /tmp/build --output-on-failure --timeout 120"
|
||||
# Not root, as dev.sh: tests refuse what root may do.
|
||||
user=1000:1000
|
||||
# Nix writes its store as root; the tests drop it.
|
||||
if [ "$distro" = nixos ]; then
|
||||
steps="HOME=/root nix build path:/src --no-link && HOME=/root nix develop path:/src --command chroot --userspec=1000:1000 --skip-chdir / env HOME=/scratch TMPDIR=/scratch sh -c '$steps'"
|
||||
user=0:0
|
||||
fi
|
||||
# The tree read-only; home and temporary files in RAM.
|
||||
docker run --rm -u "$user" -e QT_QPA_PLATFORM=offscreen -e HOME=/scratch -e TMPDIR=/scratch --tmpfs /scratch:rw,exec,size=4g \
|
||||
-v "$repo:/src:ro" -w /src "omaphoto-$distro" sh -c "$steps"
|
||||
@@ -0,0 +1,10 @@
|
||||
# Arch and Omarchy: Qt, GCC and ONNX Runtime as shipped.
|
||||
FROM archlinux:latest
|
||||
RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-imageformats libheif libde265 \
|
||||
fontconfig onnxruntime-cpu ttf-dejavu curl \
|
||||
&& pacman -Scc --noconfirm
|
||||
# U²-Net for Remove Background, pinned by its checksum.
|
||||
RUN mkdir -p /usr/local/share/omaphoto \
|
||||
&& curl -fsSL -o /usr/local/share/omaphoto/u2net.onnx \
|
||||
https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx \
|
||||
&& echo "8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491 /usr/local/share/omaphoto/u2net.onnx" | sha256sum -c -
|
||||
@@ -0,0 +1,11 @@
|
||||
# Fedora's own Qt and ONNX Runtime; HEVC from RPM Fusion.
|
||||
FROM fedora:42
|
||||
RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-42.noarch.rpm \
|
||||
&& dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtimageformats libheif-devel libheif-freeworld \
|
||||
fontconfig-devel onnxruntime-devel dejavu-sans-fonts dejavu-serif-fonts dejavu-sans-mono-fonts curl rpm-build file \
|
||||
&& dnf clean all
|
||||
# U²-Net for Remove Background, pinned by its checksum.
|
||||
RUN mkdir -p /usr/local/share/omaphoto \
|
||||
&& curl -fsSL -o /usr/local/share/omaphoto/u2net.onnx \
|
||||
https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx \
|
||||
&& echo "8d10d2f3bb75ae3b6d527c77944fc5e7dcd94b29809d47a739a7a728a912b491 /usr/local/share/omaphoto/u2net.onnx" | sha256sum -c -
|
||||
@@ -0,0 +1,3 @@
|
||||
# NixOS's package set, through the repository's own flake.
|
||||
FROM nixos/nix:latest
|
||||
RUN echo "experimental-features = nix-command flakes" >> /etc/nix/nix.conf
|
||||
Executable
+13
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
# Writes the HEIC fixtures; the dev image has no encoder.
|
||||
set -euo pipefail
|
||||
|
||||
repo=$(cd "$(dirname "$0")/.." && pwd)
|
||||
generator=$repo/scripts/heic-fixtures
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
|
||||
docker build -q -t omaphoto-heic-fixtures "$generator" >/dev/null
|
||||
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -e QT_QPA_PLATFORM=offscreen \
|
||||
-v "$generator:/generator:ro" -v "$work:/work" -v "$repo/tests/fixtures:/fixtures" -w /work \
|
||||
omaphoto-heic-fixtures /generator/generate.sh
|
||||
@@ -0,0 +1,5 @@
|
||||
FROM ubuntu:24.04
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
g++ qt6-base-dev libheif-examples libheif-plugin-x265 libheif-plugin-libde265 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Runs inside the container: /generator read-only, /fixtures to fill.
|
||||
set -eu
|
||||
|
||||
qt=/usr/include/x86_64-linux-gnu/qt6
|
||||
g++ -std=c++20 -fPIC -I$qt -I$qt/QtCore -I$qt/QtGui /generator/sources.cpp -o sources -lQt6Gui -lQt6Core
|
||||
./sources
|
||||
encode="heif-enc -q 100 -p chroma=444"
|
||||
$encode -o /fixtures/red.heic red.png
|
||||
$encode --premultiplied-alpha -o /fixtures/premultiplied.heic premultiplied.png
|
||||
$encode -o /fixtures/turned-p3.heic turned-p3.jpg
|
||||
# Lossless, no ICC profile: the nclx box names Display P3.
|
||||
heif-enc -L -p chroma=444 --matrix_coefficients=0 --colour_primaries 12 --transfer_characteristic 13 -o /fixtures/p3-nclx.heic warm.png
|
||||
for name in red premultiplied turned-p3 p3-nclx; do
|
||||
heif-info /fixtures/$name.heic | grep -E "^image|color profile|angle"
|
||||
done
|
||||
@@ -0,0 +1,46 @@
|
||||
#include <QBuffer>
|
||||
#include <QColorSpace>
|
||||
#include <QFile>
|
||||
#include <QGuiApplication>
|
||||
#include <QImage>
|
||||
#include <QImageWriter>
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QGuiApplication app(argc, argv);
|
||||
// Red on the left; right: clear over half-clear blue.
|
||||
QImage red(64, 32, QImage::Format_RGBA8888);
|
||||
red.fill(Qt::transparent);
|
||||
// (200, 100, 50) left; right: white over dark gray.
|
||||
QImage warm(64, 32, QImage::Format_RGB888);
|
||||
warm.fill(Qt::white);
|
||||
for (int y = 16; y < 32; ++y) {
|
||||
for (int x = 32; x < 64; ++x)
|
||||
warm.setPixel(x, y, qRgb(16, 16, 16));
|
||||
}
|
||||
for (int y = 0; y < 32; ++y) {
|
||||
for (int x = 0; x < 32; ++x) {
|
||||
red.setPixel(x, y, qRgba(255, 0, 0, 255));
|
||||
warm.setPixel(x, y, qRgb(200, 100, 50));
|
||||
if (y >= 16)
|
||||
red.setPixel(x + 32, y, qRgba(0, 0, 255, 128));
|
||||
}
|
||||
}
|
||||
// The same picture with its colours already multiplied.
|
||||
const QImage multiplied = red.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
|
||||
const QImage stored(multiplied.constBits(), 64, 32, multiplied.bytesPerLine(), QImage::Format_RGBA8888);
|
||||
bool written = red.save("red.png") && stored.save("premultiplied.png") && warm.save("warm.png");
|
||||
// Display P3 by ICC profile, turned by EXIF orientation 6.
|
||||
warm.setColorSpace(QColorSpace::DisplayP3);
|
||||
QByteArray jpeg;
|
||||
QBuffer buffer(&jpeg);
|
||||
QImageWriter writer(&buffer, "jpeg");
|
||||
writer.setQuality(100);
|
||||
written = written && writer.write(warm);
|
||||
QByteArray exif("\xFF\xE1\x00\x22" "Exif\0\0" "II\x2A\x00\x08\x00\x00\x00" "\x01\x00" "\x12\x01\x03\x00\x01\x00\x00\x00", 28);
|
||||
exif += QByteArray("\x06\x00\x00\x00" "\x00\x00\x00\x00", 8);
|
||||
jpeg.insert(2, exif);
|
||||
QFile file("turned-p3.jpg");
|
||||
written = written && file.open(QIODevice::WriteOnly) && file.write(jpeg) == jpeg.size();
|
||||
return written ? 0 : 1;
|
||||
}
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs OmaPhoto's latest package on Arch and Omarchy.
|
||||
set -euo pipefail
|
||||
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
|
||||
package=omaphoto-x86_64.pkg.tar.zst
|
||||
. /etc/os-release
|
||||
case " ${ID:-} ${ID_LIKE:-} " in
|
||||
*" arch "*) ;;
|
||||
*)
|
||||
echo "This script is for Arch and Omarchy; this system is ${PRETTY_NAME:-unknown}." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$(uname -m)" != x86_64 ]; then
|
||||
echo "OmaPhoto's packages are built for x86_64 alone." >&2
|
||||
exit 1
|
||||
fi
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
curl -fL --proto '=https' -o "$work/$package" "$base/$package"
|
||||
curl -fsSL --proto '=https' -o "$work/SHA256SUMS" "$base/SHA256SUMS"
|
||||
# The package must match the checksum published beside it.
|
||||
(cd "$work" && grep " $package\$" SHA256SUMS | sha256sum -c -)
|
||||
# pacman asks which ONNX Runtime build provides onnxruntime.
|
||||
sudo pacman -U "$work/$package"
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs OmaPhoto's latest RPM on Fedora.
|
||||
set -euo pipefail
|
||||
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
|
||||
package=omaphoto.x86_64.rpm
|
||||
. /etc/os-release
|
||||
case " ${ID:-} ${ID_LIKE:-} " in
|
||||
*" fedora "*) ;;
|
||||
*)
|
||||
echo "This script is for Fedora; this system is ${PRETTY_NAME:-unknown}." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$(uname -m)" != x86_64 ]; then
|
||||
echo "OmaPhoto's packages are built for x86_64 alone." >&2
|
||||
exit 1
|
||||
fi
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
curl -fL --proto '=https' -o "$work/$package" "$base/$package"
|
||||
curl -fsSL --proto '=https' -o "$work/SHA256SUMS" "$base/SHA256SUMS"
|
||||
# The package must match the checksum published beside it.
|
||||
(cd "$work" && grep " $package\$" SHA256SUMS | sha256sum -c -)
|
||||
sudo dnf install -y "$work/$package"
|
||||
# Fedora's own libheif decodes no HEVC; RPM Fusion's does.
|
||||
if ! rpm -q libheif-freeworld >/dev/null 2>&1; then
|
||||
echo "HEIC import needs RPM Fusion's libheif-freeworld: https://rpmfusion.org/Configuration, then sudo dnf install libheif-freeworld"
|
||||
fi
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs OmaPhoto on NixOS from its flake.
|
||||
set -euo pipefail
|
||||
if ! command -v nix >/dev/null; then
|
||||
echo "This script needs Nix; NixOS carries it." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ "$(uname -m)" != x86_64 ]; then
|
||||
echo "OmaPhoto's flake builds for x86_64 alone." >&2
|
||||
exit 1
|
||||
fi
|
||||
nix --extra-experimental-features "nix-command flakes" profile install github:ZacharyZhang-NY/OmaPhoto
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
# Installs OmaPhoto's latest DEB on Ubuntu and its derivatives.
|
||||
set -euo pipefail
|
||||
base=https://github.com/ZacharyZhang-NY/OmaPhoto/releases/latest/download
|
||||
package=omaphoto_amd64.deb
|
||||
. /etc/os-release
|
||||
case " ${ID:-} ${ID_LIKE:-} " in
|
||||
*" ubuntu "*) ;;
|
||||
*)
|
||||
echo "This script is for Ubuntu and its derivatives; this system is ${PRETTY_NAME:-unknown}." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
if [ "$(uname -m)" != x86_64 ]; then
|
||||
echo "OmaPhoto's packages are built for x86_64 alone." >&2
|
||||
exit 1
|
||||
fi
|
||||
work=$(mktemp -d)
|
||||
trap 'rm -rf "$work"' EXIT
|
||||
curl -fL --proto '=https' -o "$work/$package" "$base/$package"
|
||||
curl -fsSL --proto '=https' -o "$work/SHA256SUMS" "$base/SHA256SUMS"
|
||||
# The package must match the checksum published beside it.
|
||||
(cd "$work" && grep " $package\$" SHA256SUMS | sha256sum -c -)
|
||||
# apt reads the file as its own user.
|
||||
chmod a+rx "$work"
|
||||
sudo apt-get install -y "$work/$package"
|
||||
Executable
+405
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env python3
|
||||
"""House rules for OmaPhoto: files under 500 lines, comments of one line and at most ten words.
|
||||
|
||||
Reads C++ sources and tests, everything under scripts/, CMakeLists.txt and Dockerfile.dev, each with a
|
||||
scanner for its own language. Run from the repository root; exits 1 and lists each violation.
|
||||
`--self-test` checks the checker.
|
||||
"""
|
||||
import glob, io, os, re, sys, tokenize
|
||||
|
||||
|
||||
def comments(text):
|
||||
"""Yields (first line, last line, text) for each comment outside literals and numbers."""
|
||||
index, line, size = 0, 1, len(text)
|
||||
while index < size:
|
||||
char, two = text[index], text[index:index + 2]
|
||||
if two == '//':
|
||||
end = text.find('\n', index)
|
||||
end = size if end < 0 else end
|
||||
yield line, line, text[index + 2:end]
|
||||
index = end
|
||||
elif two == '/*':
|
||||
end = text.find('*/', index + 2)
|
||||
end = size if end < 0 else end
|
||||
body = text[index + 2:end]
|
||||
yield line, line + body.count('\n'), body
|
||||
line += body.count('\n')
|
||||
index = end + 2
|
||||
elif char.isdigit() or (char == '.' and text[index + 1:index + 2].isdigit()):
|
||||
# A number keeps its digit separators: 1'000, 0xFF'FF.
|
||||
index += 1
|
||||
while index < size and (text[index].isalnum() or text[index] in '._'
|
||||
or (text[index] == "'" and text[index + 1:index + 2].isalnum())):
|
||||
index += 1
|
||||
elif char.isalpha() or char == '_':
|
||||
start = index
|
||||
while index < size and (text[index].isalnum() or text[index] == '_'):
|
||||
index += 1
|
||||
if text[start:index].endswith('R') and text[index:index + 1] == '"':
|
||||
opened = text.index('(', index)
|
||||
closing = ')' + text[index + 1:opened] + '"'
|
||||
end = text.index(closing, opened) + len(closing)
|
||||
line += text.count('\n', index, end)
|
||||
index = end
|
||||
elif char in '"\'':
|
||||
start, index = index, index + 1
|
||||
while index < size and text[index] != char:
|
||||
index += 2 if text[index] == '\\' else 1
|
||||
index += 1
|
||||
line += text.count('\n', start, index)
|
||||
else:
|
||||
line += char == '\n'
|
||||
index += 1
|
||||
|
||||
|
||||
def python_comments(text):
|
||||
"""Yields (line, line, text) for each comment of a Python file; its tokenizer knows strings."""
|
||||
for token in tokenize.generate_tokens(io.StringIO(text).readline):
|
||||
if token.type == tokenize.COMMENT and not token.string.startswith('#!'):
|
||||
yield token.start[0], token.start[0], token.string[1:]
|
||||
|
||||
|
||||
def docker_comments(text):
|
||||
"""Yields (line, line, text): Docker drops every line that begins with `#`, wherever it stands."""
|
||||
for number, row in enumerate(text.splitlines(), 1):
|
||||
if row.strip().startswith('#'):
|
||||
yield number, number, row.strip()[1:]
|
||||
|
||||
|
||||
class Unreadable(Exception):
|
||||
"""Shell the scanner refuses to read; the file fails at that line."""
|
||||
|
||||
|
||||
def shell_comments(text):
|
||||
"""Returns (line, line, text) for each comment of a shell script.
|
||||
|
||||
Reads the shell our scripts keep to: quotes, escapes, `$( )` and `${ }` to any depth, here-strings,
|
||||
and `#` where a word begins. What it cannot read exactly it refuses by raising Unreadable, so nothing
|
||||
passes unread: `<<` (a heredoc or a shift), backticks, `$'…'`, `((`, `case` inside `$( )`, and a
|
||||
continued line anywhere but between words (it could split `$(`, `<<` or a keyword).
|
||||
"""
|
||||
index, line, size, found = 0, 1, len(text), []
|
||||
|
||||
def refuse(what):
|
||||
raise Unreadable('%d: lint does not read %s' % (line, what))
|
||||
|
||||
def single():
|
||||
nonlocal index, line
|
||||
end = text.find("'", index + 1)
|
||||
if end < 0:
|
||||
refuse('a quote left open')
|
||||
line += text.count('\n', index, end)
|
||||
index = end + 1
|
||||
|
||||
def expansion():
|
||||
"""One step inside `"…"` or `${…}`: an escape, a substitution, or a character."""
|
||||
nonlocal index, line
|
||||
if text[index] == '$' and text.startswith('\\\n', index + 1):
|
||||
refuse('a continued line that splits `$` from what it opens')
|
||||
elif text[index] == '\\':
|
||||
line += text[index + 1:index + 2] == '\n'
|
||||
index += 2
|
||||
elif text[index] == '`':
|
||||
refuse('backticks')
|
||||
elif text.startswith('$((', index):
|
||||
refuse('arithmetic')
|
||||
elif text.startswith('$(', index):
|
||||
index += 2
|
||||
code(')')
|
||||
elif text.startswith('${', index):
|
||||
index += 2
|
||||
enclosed('}', 'a brace left open')
|
||||
else:
|
||||
line += text[index] == '\n'
|
||||
index += 1
|
||||
|
||||
def enclosed(closer, otherwise):
|
||||
"""To the `closer` of `"…"` or `${…}`; strings nest inside braces."""
|
||||
nonlocal index
|
||||
if closer == '"':
|
||||
index += 1
|
||||
while index < size and text[index] != closer:
|
||||
if closer == '}' and text[index] == "'":
|
||||
single()
|
||||
elif closer == '}' and text[index] == '"':
|
||||
enclosed('"', 'a quote left open')
|
||||
else:
|
||||
expansion()
|
||||
if index >= size:
|
||||
refuse(otherwise)
|
||||
index += 1
|
||||
|
||||
def code(closer):
|
||||
"""Shell code to `closer`, or to the end of the text."""
|
||||
nonlocal index, line
|
||||
word, depth = False, 0
|
||||
while index < size:
|
||||
char = text[index]
|
||||
if char == '\\':
|
||||
# A continued line is nothing; other escapes are words.
|
||||
continued = text[index + 1:index + 2] == '\n'
|
||||
if continued and index > 0 and text[index - 1] not in ' \t\n':
|
||||
refuse('a continued line that splits a word or an operator')
|
||||
line, word, index = line + continued, word or not continued, index + 2
|
||||
elif char in '\'"':
|
||||
single() if char == "'" else enclosed('"', 'a quote left open')
|
||||
word = True
|
||||
elif char == '`' or text.startswith("$'", index):
|
||||
refuse('backticks' if char == '`' else "$'…' quoting")
|
||||
elif text.startswith('${', index) or text.startswith('$(', index):
|
||||
expansion()
|
||||
word = True
|
||||
elif text.startswith('<<<', index):
|
||||
index, word = index + 3, False
|
||||
elif text.startswith('<<', index) or text.startswith('((', index):
|
||||
refuse('`<<`: a heredoc or a shift' if char == '<' else '`((`')
|
||||
elif char == ')' and closer and depth == 0:
|
||||
index += 1
|
||||
return
|
||||
elif char in ' \t\n;&|<>()':
|
||||
depth += (char == '(') - (char == ')')
|
||||
line, word, index = line + (char == '\n'), False, index + 1
|
||||
elif char == '#' and not word:
|
||||
end = text.find('\n', index)
|
||||
end = size if end < 0 else end
|
||||
found.append((line, line, text[index + 1:end]))
|
||||
index = end
|
||||
else:
|
||||
if closer and not word and re.compile(r'case\b').match(text, index):
|
||||
refuse('`case` inside `$( )`')
|
||||
index, word = index + 1, True
|
||||
if closer:
|
||||
refuse('`$(` left open')
|
||||
|
||||
if text.startswith('#!'):
|
||||
index = text.find('\n') if '\n' in text else size
|
||||
code(None)
|
||||
return found
|
||||
|
||||
|
||||
def cmake_comments(text):
|
||||
"""Yields (first line, last line, text) for each comment of a CMake file.
|
||||
|
||||
`"` strings and `\\` escapes, bracket arguments `[=[ ]=]` where an argument begins (inside an unquoted
|
||||
argument, escapes included, `[[` is text), bracket comments `#[=[ ]=]`, and `#` anywhere else.
|
||||
"""
|
||||
index, line, size, argument = 0, 1, len(text), False
|
||||
while index < size:
|
||||
char = text[index]
|
||||
if char == '\\':
|
||||
line += text[index + 1:index + 2] == '\n'
|
||||
index, argument = index + 2, True
|
||||
elif char == '"':
|
||||
# A quote inside an unquoted argument is part of it.
|
||||
start, index = index, index + 1
|
||||
while index < size and text[index] != '"':
|
||||
index += 2 if text[index] == '\\' else 1
|
||||
index += 1
|
||||
line += text.count('\n', start, index)
|
||||
elif (bracket := re.compile(r'#?\[(=*)\[').match(text, index)) and (char == '#' or not argument):
|
||||
closer = ']' + bracket.group(1) + ']'
|
||||
close = text.find(closer, bracket.end())
|
||||
close = size if close < 0 else close
|
||||
lines = text.count('\n', index, close)
|
||||
if bracket.group(0).startswith('#'):
|
||||
yield line, line + lines, text[bracket.end():close]
|
||||
line, index, argument = line + lines, close + len(closer), False
|
||||
elif char == '#':
|
||||
end = text.find('\n', index)
|
||||
end = size if end < 0 else end
|
||||
yield line, line, text[index + 1:end]
|
||||
index = end
|
||||
else:
|
||||
# An unquoted argument runs to a blank or a parenthesis.
|
||||
line, index, argument = line + (char == '\n'), index + 1, char not in ' \t\n()'
|
||||
|
||||
|
||||
def violations(path, text):
|
||||
found = []
|
||||
if len(text.splitlines()) >= 500:
|
||||
found.append('%s: %d lines' % (path, len(text.splitlines())))
|
||||
rows = text.splitlines()
|
||||
previous = None
|
||||
name = path.rsplit('/', 1)[-1]
|
||||
scanners = [(('.cpp', '.h'), comments), (('.py',), python_comments), (('.sh',), shell_comments),
|
||||
(('CMakeLists.txt', '.cmake'), cmake_comments)]
|
||||
scanner = docker_comments if name.startswith('Dockerfile') else next((s for ends, s in scanners if name.endswith(ends)), None)
|
||||
if scanner is None:
|
||||
return found + ['%s: lint has no scanner for this kind of file' % path]
|
||||
try:
|
||||
found_comments = list(scanner(text))
|
||||
except Unreadable as refusal:
|
||||
return found + ['%s:%s' % (path, refusal)]
|
||||
for first, last, body in found_comments:
|
||||
if last > first:
|
||||
found.append('%s:%d: comment runs over one line' % (path, first))
|
||||
alone = rows[first - 1].strip().startswith(('//', '/*', '#'))
|
||||
if alone and previous == first - 1:
|
||||
found.append('%s:%d: comment runs over one line' % (path, first))
|
||||
previous = last if alone else None
|
||||
if len(body.split()) > 10:
|
||||
found.append('%s:%d: comment of %d words' % (path, first, len(body.split())))
|
||||
return found
|
||||
|
||||
|
||||
def self_test():
|
||||
eleven = 'one two three four five six seven eight nine ten eleven'
|
||||
cases = [
|
||||
('int a; // fine\n', 0),
|
||||
('// ' + eleven + '\n', 1),
|
||||
('/* ' + eleven + ' */\n', 1),
|
||||
('/* two\n lines */\n', 1),
|
||||
('// one\n// two\n', 1),
|
||||
('int a; // one\nint b; // two\n', 0),
|
||||
('const char *url = "http://example.com/a b c d e f g h i j k l";\n', 0),
|
||||
("char slash = '/'; char quote = '\"'; // fine\n", 0),
|
||||
('auto raw = R"x(a quote " then // ' + eleven + ')x"; // fine\n', 0),
|
||||
('const char *escaped = "a \\" // b c d e f g h i j k l m";\n', 0),
|
||||
("int value = 1'000; // " + eleven + '\n', 1),
|
||||
("int mask = 0xFF'FF; int next = 2'0; // " + eleven + '\n', 1),
|
||||
("double big = 1e+5; char c = 'x'; // fine\n", 0),
|
||||
("auto wide = u8R\"(a quote \" then // " + eleven + ")\"; // fine\n", 0),
|
||||
('const char *value = "first\\\nsecond";\n// first\n// second\n', 1),
|
||||
('auto raw = R"(one\ntwo)";\n// first\n// second\n', 1),
|
||||
('int a;\n' * 499, 0),
|
||||
('int a;\n' * 500, 1),
|
||||
('int a;\n' * 498 + 'int a;', 0),
|
||||
('int a;\n' * 499 + 'int a;', 1),
|
||||
]
|
||||
for text, expected in cases:
|
||||
got = violations('fixture.cpp', text)
|
||||
assert len(got) == expected, (text[:60], expected, got)
|
||||
ten = eleven[:-7]
|
||||
scripts = [
|
||||
('fixture.sh', '#!/usr/bin/env bash ' + eleven + '\necho hello\n', 0, ''),
|
||||
('fixture.sh', '# ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', '# ' + ten + '\n', 0, ''),
|
||||
('fixture.sh', '#' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', '#\t' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'echo hello # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'echo hello\t# ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'echo hello;# ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', '(# ' + eleven + '\necho)\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'sleep 1 &# ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'true ||# ' + eleven + '\ntrue\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'echo >#' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'name() { echo; } # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'case x in\n a) echo;; # ' + eleven + '\nesac\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'echo "# ' + eleven + '"\n', 0, ''),
|
||||
('fixture.sh', 'echo "first\n# ' + eleven + '\nlast"\n', 0, ''),
|
||||
('fixture.sh', "sh -c '\n# " + eleven + "\n'\n", 0, ''),
|
||||
('fixture.sh', 'echo "a \\" # ' + eleven + '"\n', 0, ''),
|
||||
('fixture.sh', "echo 'a \\' # " + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'echo \\" # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'echo a#b ' + eleven + '\n', 0, ''),
|
||||
('fixture.sh', 'echo a\\ #' + eleven + '\n', 0, ''),
|
||||
('fixture.sh', 'echo a\\\n#b ' + eleven + '\n', 1, 'lint does not read a continued line that splits a word'),
|
||||
('fixture.sh', 'echo a \\\n# ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'echo a \\\n b\n# one\n# two\n', 1, ':4: comment runs over one line'),
|
||||
('fixture.sh', 'echo $# ${#name} ${name#a b} ${name:-# c} ' + eleven + '\n', 0, ''),
|
||||
('fixture.sh', 'echo \\# ' + eleven + '\n', 0, ''),
|
||||
('fixture.sh', 'echo $# # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'value="$(echo hi # ' + eleven + '\n)"\n', 1, ':1: comment of 11 words'),
|
||||
('fixture.sh', 'value="$(echo "in $(echo deep # ' + eleven + '\n)")"\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'value="${name:-$(echo # ' + eleven + '\n)}"\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'value=$( (cd /tmp; pwd) ) # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'value="$(echo \\) # ' + eleven + '\n)"\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'value="$(echo \\))" # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'cat <<< "text" # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'cat <<< text\n# ' + eleven + '\n', 1, ':2: comment of 11 words'),
|
||||
('fixture.sh', 'echo\ncat <<EOF\nbody\nEOF\n', 1, ':2: lint does not read `<<`'),
|
||||
('fixture.sh', 'cat <<-END-TEXT\nbody\nEND-TEXT\n', 1, 'lint does not read `<<`'),
|
||||
('fixture.sh', 'value=$((1 << shift))\n', 1, 'lint does not read arithmetic'),
|
||||
('fixture.sh', 'value="$((1 + 2))"\n', 1, 'lint does not read arithmetic'),
|
||||
('fixture.sh', '((count += 1))\n', 1, 'lint does not read `((`'),
|
||||
('fixture.sh', 'echo `date`\n', 1, 'lint does not read backticks'),
|
||||
('fixture.sh', 'echo "`date`"\n', 1, 'lint does not read backticks'),
|
||||
('fixture.sh', "echo $'it\\'s'\n", 1, "lint does not read $'"),
|
||||
('fixture.sh', 'value=$(case x in a) echo;; esac)\n', 1, 'lint does not read `case` inside'),
|
||||
('fixture.sh', 'value=$(echo showcase)\n', 0, ''),
|
||||
('fixture.sh', 'echo "open\n', 1, 'a quote left open'),
|
||||
('fixture.sh', "echo 'open\n", 1, 'a quote left open'),
|
||||
('fixture.sh', 'echo ${open\n', 1, 'a brace left open'),
|
||||
('fixture.sh', 'value=$(echo open\n', 1, '`$(` left open'),
|
||||
('fixture.sh', "echo 'a\nb'\n# one\n# two\n", 1, ':4: comment runs over one line'),
|
||||
('fixture.sh', 'echo "a\nb"\n# one\n# two\n', 1, ':4: comment runs over one line'),
|
||||
('fixture.sh', "echo ${name:-'}'} # " + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'echo ${name:-"}"} # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'echo "it\'s" # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'echo \\ #' + eleven + '\n', 0, ''),
|
||||
('fixture.sh', "echo 'a'#b " + eleven + '\n', 0, ''),
|
||||
('fixture.sh', 'echo "a"#b ' + eleven + '\n', 0, ''),
|
||||
('fixture.sh', 'echo $(date)#b ${name}#c ' + eleven + '\n', 0, ''),
|
||||
('fixture.sh', 'cat <<<#' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'value="$( (echo a) # ' + eleven + '\n)"\n', 1, 'comment of 11 words'),
|
||||
('fixture.sh', 'value=$(caseless)\n', 0, ''),
|
||||
('fixture.sh', 'value="$\\\n(printf x # ' + eleven + '\n)"\n', 1, ':1: lint does not read a continued line that splits `$`'),
|
||||
('fixture.sh', 'value=$\\\n(printf x # ' + eleven + '\n)\n', 1, 'lint does not read a continued line'),
|
||||
('fixture.sh', 'value=$(\\\n(1 + 2))\n', 1, 'lint does not read a continued line'),
|
||||
('fixture.sh', 'cat <\\\n<EOF\n# ' + eleven + '\nEOF\n', 1, 'lint does not read a continued line'),
|
||||
('fixture.sh', 'value=$(ca\\\nse x in a) echo;; esac)\n', 1, 'lint does not read a continued line'),
|
||||
('fixture.sh', 'echo one \\\n\ttwo \\\n\\\n three # ' + eleven + '\n', 1, ':4: comment of 11 words'),
|
||||
('fixture.sh', 'echo "one\\\ntwo" # ' + eleven + '\n', 1, ':2: comment of 11 words'),
|
||||
('fixture.sh', 'echo one\t\\\n two # ' + eleven + '\n', 1, ':2: comment of 11 words'),
|
||||
('fixture.sh', '# one\n# two\n', 1, 'comment runs over one line'),
|
||||
('fixture.sh', '# one\n#\n', 1, 'comment runs over one line'),
|
||||
('fixture.sh', '# one\necho\n# two\n', 0, ''),
|
||||
('fixture.sh', 'echo one # first\necho two # second\n', 0, ''),
|
||||
('CMakeLists.txt', ' # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('CMakeLists.txt', 'set(NAME value)#' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('CMakeLists.txt', 'set(NAME "# ' + eleven + '")\n', 0, ''),
|
||||
('CMakeLists.txt', 'set(NAME "a \\" # ' + eleven + '")\n', 0, ''),
|
||||
('CMakeLists.txt', 'set(NAME a\\# ' + eleven + ')\n', 0, ''),
|
||||
('CMakeLists.txt', "message(it's) # " + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('CMakeLists.txt', 'set(NAME [[\n# ' + eleven + '\n]])\n', 0, ''),
|
||||
('CMakeLists.txt', 'set(NAME [=[ ]] # ' + eleven + ' ]=])\n', 0, ''),
|
||||
('CMakeLists.txt', 'set(NAME a[[b # ' + eleven + ' ]])\n', 1, 'comment of 12 words'),
|
||||
('CMakeLists.txt', 'set(NAME\t[[b # ' + eleven + ' ]])\n', 0, ''),
|
||||
('CMakeLists.txt', '[[ # ' + eleven + ' ]]', 0, ''),
|
||||
('CMakeLists.txt', 'set(NAME a\\ [[b # ' + eleven + '\n]])\n', 1, 'comment of 11 words'),
|
||||
('CMakeLists.txt', 'set(NAME "a"[[b # ' + eleven + ' ]])\n', 0, ''),
|
||||
('CMakeLists.txt', 'set(NAME a"b"[[c # ' + eleven + '\n]])\n', 1, 'comment of 11 words'),
|
||||
('CMakeLists.txt', 'set(NAME a"b # c"[[d # ' + eleven + '\n]])\n', 1, 'comment of 11 words'),
|
||||
('CMakeLists.txt', 'set(NAME a)[[b # ' + eleven + ' ]]\n', 0, ''),
|
||||
('CMakeLists.txt', 'set([[b # ' + eleven + ' ]])\n', 0, ''),
|
||||
('CMakeLists.txt', 'set(NAME\n[[b # ' + eleven + ' ]])\n', 0, ''),
|
||||
('CMakeLists.txt', 'set(NAME [[a]][[b # ' + eleven + ' ]])\n', 0, ''),
|
||||
('CMakeLists.txt', '# first\nset(NAME\n# ' + ten + '\n[[b # ' + eleven + ' ]])\n', 0, ''),
|
||||
('CMakeLists.txt', 'set(NAME a [==[b # ' + eleven + ' ]==] c[[d # ' + eleven + '\n)\n', 1, 'comment of 11 words'),
|
||||
('CMakeLists.txt', 'set(NAME a#[[ ' + eleven + ' ]]\n)\n', 1, 'comment of 11 words'),
|
||||
('CMakeLists.txt', '#[[ ' + ten + ' ]]\nset(NAME value)\n', 0, ''),
|
||||
('CMakeLists.txt', '#[==[ ' + ten + ' ]==]\n', 0, ''),
|
||||
('CMakeLists.txt', '#[[ two\nlines ]]\n', 1, 'comment runs over one line'),
|
||||
('CMakeLists.txt', '#[=[ ' + eleven + ' ]=]\n', 1, 'comment of 11 words'),
|
||||
('helpers.cmake', '# ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('Dockerfile.dev', '# fine\nFROM ubuntu:24.04\n', 0, ''),
|
||||
('Dockerfile.dev', ' # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('Dockerfile.dev', 'RUN echo "a # ' + eleven + '"\n', 0, ''),
|
||||
('Dockerfile.dev', 'RUN echo "\\\n# ' + eleven + '\\\n"\n', 1, 'comment of 11 words'),
|
||||
('Dockerfile', '# ' + ten + '\n', 0, ''),
|
||||
('fixture.py', 'value = "# ' + eleven + '" # fine\n', 0, ''),
|
||||
('fixture.py', 'value = 1 # ' + eleven + '\n', 1, 'comment of 11 words'),
|
||||
('fixture.py', 'value = 1 # ' + ten + '\n', 0, ''),
|
||||
('fixture.py', '#!/usr/bin/env python3 ' + eleven + '\nvalue = 1\n', 0, ''),
|
||||
('fixture.py', '"""' + eleven + '\n' + eleven + '"""\n', 0, ''),
|
||||
('fixture.py', '# one\n# two\n', 1, 'comment runs over one line'),
|
||||
('fixture.py', "value = '''it's # " + eleven + "'''\n", 0, ''),
|
||||
('notes.txt', 'plain text\n', 1, 'no scanner for this kind of file'),
|
||||
('fixture.sh', 'echo\n' * 500, 1, '500 lines'),
|
||||
]
|
||||
for path, text, expected, message in scripts:
|
||||
got = violations(path, text)
|
||||
assert len(got) == expected and all(message in line for line in got), (path, text[:60], expected, message, got)
|
||||
print('lint self-test: %d cases pass' % (len(cases) + len(scripts)))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
if sys.argv[1:] == ['--self-test']:
|
||||
self_test()
|
||||
sys.exit(0)
|
||||
paths = sorted(p for p in glob.glob('src/**/*', recursive=True) + glob.glob('tests/*') if p.endswith(('.cpp', '.h')))
|
||||
paths += sorted(p for p in glob.glob('scripts/**/*', recursive=True) if os.path.isfile(p))
|
||||
paths += ['CMakeLists.txt', 'Dockerfile.dev']
|
||||
failures = [failure for path in paths for failure in violations(path, open(path).read())]
|
||||
print('\n'.join(failures) if failures else 'lint: %d files clean' % len(paths))
|
||||
sys.exit(1 if failures else 0)
|
||||
Executable
+102
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Mutation runner for OmaPhoto. Run from the repository root.
|
||||
|
||||
mutate.py FILE [--targets T1,T2] <<< 'old ==> new' blocks separated by lines of @@
|
||||
mutate.py FILE --delete [--from N --to M] [--targets T1,T2] delete each statement line in turn
|
||||
|
||||
Verdicts: 'caught' means the mutant built and a test that really ran failed; 'UNBUILDABLE' means the
|
||||
compiler rejected it, which proves nothing; 'SURVIVED' means every test passed. Anything else (Docker
|
||||
not starting, a missing test executable, a failing baseline) stops the run with an error.
|
||||
"""
|
||||
import argparse, os, re, subprocess, sys
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('path')
|
||||
parser.add_argument('--targets', default='')
|
||||
parser.add_argument('--delete', action='store_true')
|
||||
parser.add_argument('--from', dest='first', type=int, default=1)
|
||||
parser.add_argument('--to', dest='last', type=int, default=10**9)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = os.getcwd()
|
||||
if not os.path.isfile(os.path.join(repo, 'CMakeLists.txt')):
|
||||
sys.exit('run mutate.py from the repository root')
|
||||
original = open(args.path).read()
|
||||
targets = [t for t in args.targets.split(',') if t]
|
||||
|
||||
|
||||
def docker(command):
|
||||
uid, gid = os.getuid(), os.getgid()
|
||||
runtime = '/run/user/%d' % uid
|
||||
done = subprocess.run(
|
||||
['docker', 'run', '--rm', '--init', '-u', '%d:%d' % (uid, gid), '-e', 'HOME=/tmp', '-e', 'QT_QPA_PLATFORM=offscreen',
|
||||
'--tmpfs', '%s:uid=%d,gid=%d,mode=0700' % (runtime, uid, gid), '-e', 'XDG_RUNTIME_DIR=' + runtime,
|
||||
'-v', '%s:%s' % (repo, repo), '-w', repo, 'omaphoto-dev', 'timeout', '600', 'sh', '-c', command],
|
||||
capture_output=True, text=True)
|
||||
return done.returncode, done.stdout + done.stderr
|
||||
|
||||
|
||||
def build():
|
||||
wanted = ' --target ' + ' '.join(targets) if targets else ''
|
||||
return docker('cmake -S . -B build -G Ninja -DOMAPHOTO_WERROR=ON >/dev/null && cmake --build build' + wanted)
|
||||
|
||||
|
||||
def test():
|
||||
if targets:
|
||||
missing = [t for t in targets if not os.path.isfile(os.path.join(repo, 'build', t))]
|
||||
if missing:
|
||||
sys.exit('test executables are missing after a successful build: %s' % missing)
|
||||
return docker(' && '.join('./build/%s' % t for t in targets))
|
||||
return docker('ctest --test-dir build --output-on-failure --timeout 120')
|
||||
|
||||
|
||||
def judge():
|
||||
code, out = build()
|
||||
if code != 0:
|
||||
if 'error:' in out or 'FAILED:' in out:
|
||||
return 'UNBUILDABLE', next((l for l in out.split('\n') if 'error:' in l), '')
|
||||
sys.exit('the build did not run:\n' + out[-2000:])
|
||||
code, out = test()
|
||||
if code == 0:
|
||||
return 'SURVIVED', ''
|
||||
if 'Start testing of' not in out and 'Timeout' not in out:
|
||||
sys.exit('the tests did not run:\n' + out[-2000:])
|
||||
return 'caught', next((l for l in out.split('\n') if l.startswith('FAIL!') or 'Timeout' in l or 'Segmentation' in l), 'test process died')
|
||||
|
||||
|
||||
mutants = []
|
||||
if args.delete:
|
||||
skipped = ('const ', 'static ', '#', '//', 'using ', 'namespace ', 'struct ', 'class ', '}', 'return', 'break', 'continue')
|
||||
lines = original.split('\n')
|
||||
for number, line in enumerate(lines, 1):
|
||||
stripped = line.strip()
|
||||
if not (args.first <= number <= args.last) or not stripped.endswith(';') or stripped.startswith(skipped):
|
||||
continue
|
||||
mutated = lines[:]
|
||||
mutated[number - 1] = line[:len(line) - len(line.lstrip())] + '(void)0;'
|
||||
mutants.append(('L%d %s' % (number, stripped), '\n'.join(mutated)))
|
||||
else:
|
||||
for block in sys.stdin.read().split('\n@@\n'):
|
||||
block = block.strip('\n')
|
||||
if not block:
|
||||
continue
|
||||
old, new = block.split(' ==> ')
|
||||
if original.count(old) != 1:
|
||||
sys.exit('the text to mutate must occur exactly once: %r occurs %d times' % (old, original.count(old)))
|
||||
mutants.append((old.replace('\n', ' '), original.replace(old, new)))
|
||||
|
||||
verdict, reason = judge()
|
||||
if verdict != 'SURVIVED':
|
||||
sys.exit('the unmutated baseline is not green (%s): %s' % (verdict, reason))
|
||||
|
||||
counts = {'caught': 0, 'SURVIVED': 0, 'UNBUILDABLE': 0}
|
||||
try:
|
||||
for label, text in mutants:
|
||||
open(args.path, 'w').write(text)
|
||||
verdict, reason = judge()
|
||||
counts[verdict] += 1
|
||||
print('%-11s | %-70s | %s' % (verdict, label[:70], reason[:100]))
|
||||
sys.stdout.flush()
|
||||
finally:
|
||||
open(args.path, 'w').write(original)
|
||||
print('mutants: %d caught: %d survivors: %d unbuildable: %d' % (len(mutants), counts['caught'], counts['SURVIVED'], counts['UNBUILDABLE']))
|
||||
Reference in new issue
Block a user