Made .lmsignore more like .gitignore, fixes #486

This commit is contained in:
emeric
2026-06-02 16:24:01 +02:00
parent cad6d41d27
commit 44c11e85ff
14 changed files with 429 additions and 48 deletions
+1 -12
View File
@@ -21,8 +21,6 @@
#include <algorithm>
#include <array>
#include <cassert>
#include <unistd.h>
#include "core/String.hpp"
@@ -35,22 +33,13 @@ namespace lms::core::pathUtils
return (std::find(std::cbegin(supportedExtensions), std::cend(supportedExtensions), extension) != std::cend(supportedExtensions));
}
bool isPathInRootPath(const std::filesystem::path& path, const std::filesystem::path& rootPathArg, const std::filesystem::path* excludeDirFileName)
bool isPathInRootPath(const std::filesystem::path& path, const std::filesystem::path& rootPathArg)
{
std::filesystem::path curPath{ path };
std::filesystem::path rootPath{ rootPathArg.has_filename() ? rootPathArg : rootPathArg.parent_path() };
while (true)
{
if (excludeDirFileName && !excludeDirFileName->empty())
{
assert(!excludeDirFileName->has_parent_path());
std::error_code ec;
if (std::filesystem::exists(curPath / *excludeDirFileName, ec))
return false;
}
if (curPath == rootPath)
return true;
+2 -3
View File
@@ -29,9 +29,8 @@ namespace lms::core::pathUtils
// Check if file's extension is one of provided extensions
bool hasFileAnyExtension(const std::filesystem::path& file, std::span<const std::filesystem::path> extensions);
// Check if a path is within a directory (excludeDirFileName is a relative can be used to exclude a whole directory and its subdirectory, must not have parent_path)
// Caller responsibility to call with normalized paths
bool isPathInRootPath(const std::filesystem::path& path, const std::filesystem::path& rootPath, const std::filesystem::path* excludeDirFileName = {});
// Check if a path is within a directory. Caller responsibility to call with normalized paths.
bool isPathInRootPath(const std::filesystem::path& path, const std::filesystem::path& rootPath);
std::filesystem::path getLongestCommonPath(const std::filesystem::path& path1, const std::filesystem::path& path2);
+1
View File
@@ -35,6 +35,7 @@ add_library(lmsscanner STATIC
impl/steps/ScanStepScanFiles.cpp
impl/steps/ScanStepUpdateLibraryFields.cpp
impl/FileScanners.cpp
impl/IgnoreRules.cpp
impl/ScannerService.cpp
impl/ScannerServiceTraceLogger.cpp
impl/ScannerStats.cpp
@@ -0,0 +1,120 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include "IgnoreRules.hpp"
#include <fnmatch.h>
#include <fstream>
namespace lms::scanner
{
IgnoreRules::IgnoreRules(std::string_view content)
{
std::string_view remaining{ content };
while (!remaining.empty())
{
const std::string_view::size_type newlinePos{ remaining.find('\n') };
std::string_view line{ remaining.substr(0, newlinePos) };
remaining = (newlinePos != std::string_view::npos) ? remaining.substr(newlinePos + 1) : std::string_view{};
if (!line.empty() && line.back() == '\r')
line.remove_suffix(1);
if (line.empty() || line.front() == '#')
continue;
Rule rule{};
if (line.front() == '!')
{
rule.negate = true;
line.remove_prefix(1);
}
if (!line.empty() && line.back() == '/')
{
rule.dirOnly = true;
line.remove_suffix(1);
}
// A leading / anchors the pattern to the root.
bool anchored{};
if (!line.empty() && line.front() == '/')
{
anchored = true;
line.remove_prefix(1);
}
if (line.empty())
continue;
rule.pattern = std::string{ line };
rule.mode = (anchored || rule.pattern.find('/') != std::string::npos) ? MatchMode::FullPath : MatchMode::BasenameOnly;
_rules.push_back(std::move(rule));
}
}
bool IgnoreRules::isEmpty() const
{
return _rules.empty();
}
bool IgnoreRules::isIgnored(const std::filesystem::path& relativePath, IsDirectory isDir) const
{
if (_rules.empty())
return false;
bool ignored{};
for (const Rule& rule : _rules)
{
if (rule.dirOnly && !isDir.value())
continue;
bool matched{};
switch (rule.mode)
{
case MatchMode::BasenameOnly:
{
const std::filesystem::path basename{ relativePath.filename() };
matched = (::fnmatch(rule.pattern.c_str(), basename.c_str(), 0) == 0);
}
break;
case MatchMode::FullPath:
matched = (::fnmatch(rule.pattern.c_str(), relativePath.c_str(), FNM_PATHNAME) == 0);
break;
}
if (matched)
ignored = !rule.negate;
}
return ignored;
}
IgnoreRules loadIgnoreRules(const std::filesystem::path& path)
{
std::ifstream file{ path };
if (!file.is_open())
return IgnoreRules{ {} };
return IgnoreRules{ std::string{ std::istreambuf_iterator<char>{ file }, std::istreambuf_iterator<char>{} } };
}
} // namespace lms::scanner
@@ -0,0 +1,67 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#pragma once
#include <filesystem>
#include <string>
#include <string_view>
#include <vector>
#include "core/TaggedType.hpp"
namespace lms::scanner
{
// Parsed representation of a .lmsignore file. See README.md for supported syntax.
class IgnoreRules
{
public:
explicit IgnoreRules(std::string_view content);
bool isEmpty() const;
using IsDirectory = core::TaggedBool<struct IsDirectoryTag>;
bool isIgnored(const std::filesystem::path& relativePath, IsDirectory isDir) const;
private:
enum class MatchMode
{
BasenameOnly, // no '/' in pattern: matched against basename only
FullPath, // has '/': matched against full relative path
};
struct Rule
{
bool negate;
bool dirOnly;
MatchMode mode;
std::string pattern;
bool operator==(const Rule&) const = default;
};
std::vector<Rule> _rules;
public:
bool operator==(const IgnoreRules&) const = default;
};
// returns an empty IgnoreRules if the file does not exist or cannot be opened.
IgnoreRules loadIgnoreRules(const std::filesystem::path& path);
} // namespace lms::scanner
@@ -23,6 +23,8 @@
#include "database/objects/MediaLibraryId.hpp"
#include "IgnoreRules.hpp"
namespace lms::scanner
{
struct MediaLibraryInfo
@@ -30,7 +32,8 @@ namespace lms::scanner
db::MediaLibraryId id;
std::filesystem::path rootDirectory;
bool firstScan{};
IgnoreRules ignoreRules{ {} };
auto operator<=>(const MediaLibraryInfo& other) const = default;
bool operator==(const MediaLibraryInfo&) const = default;
};
} // namespace lms::scanner
@@ -108,6 +108,7 @@ namespace lms::scanner
info.firstScan = mediaLibrary->isEmpty();
info.id = mediaLibrary->getId();
info.rootDirectory = mediaLibrary->getPath().lexically_normal();
info.ignoreRules = loadIgnoreRules(info.rootDirectory / ".lmsignore");
settings->mediaLibraries.push_back(info);
});
@@ -31,8 +31,6 @@
namespace lms::scanner
{
static inline const std::filesystem::path excludeDirFileName{ ".lmsignore" };
struct ScannerSettings
{
std::size_t audioScanVersion{};
@@ -50,6 +50,7 @@
#include "services/scanner/ScanErrors.hpp"
#include "IgnoreRules.hpp"
#include "ScannerSettings.hpp"
#include "helpers/ArtistHelpers.hpp"
#include "scanners/IFileScanOperation.hpp"
@@ -645,15 +646,17 @@ namespace lms::scanner
if (track && track->getId() == otherTrack->getId())
continue;
// Skip if duplicate files no longer in media root: as it will be removed later, we will end up with no file
auto& mediaLibraries{ getScannerSettings().mediaLibraries };
if (std::none_of(std::cbegin(mediaLibraries), std::cend(mediaLibraries),
[&](const MediaLibraryInfo& libraryInfo) {
return core::pathUtils::isPathInRootPath(getFilePath(), libraryInfo.rootDirectory, &excludeDirFileName);
}))
{
// If the other file is no longer in an active library it will be removed later:
// do not treat the current file as a duplicate, otherwise no file will remain for this MBID
const std::filesystem::path otherFilePath{ otherTrack->getAbsoluteFilePath() };
const auto isInActiveLibrary{ [&](const MediaLibraryInfo& libraryInfo) {
if (!core::pathUtils::isPathInRootPath(otherFilePath, libraryInfo.rootDirectory))
return false;
return libraryInfo.ignoreRules.isEmpty() || !libraryInfo.ignoreRules.isIgnored(std::filesystem::relative(otherFilePath, libraryInfo.rootDirectory), IgnoreRules::IsDirectory{ false });
} };
const auto& mediaLibraries{ getScannerSettings().mediaLibraries };
if (!std::any_of(std::cbegin(mediaLibraries), std::cend(mediaLibraries), isInActiveLibrary))
continue;
}
LMS_LOG(DBUPDATER, DEBUG, "Skipped " << getFilePath() << ": same MBID already found in " << otherTrack->getAbsoluteFilePath());
// As this MBID already exists, just remove what we just scanned
@@ -36,6 +36,7 @@
#include "database/objects/TrackLyrics.hpp"
#include "FileScanners.hpp"
#include "IgnoreRules.hpp"
#include "JobQueue.hpp"
#include "ScanContext.hpp"
#include "ScannerSettings.hpp"
@@ -103,10 +104,12 @@ namespace lms::scanner
return false;
}
if (std::none_of(std::cbegin(_settings.mediaLibraries), std::cend(_settings.mediaLibraries),
[&](const MediaLibraryInfo& libraryInfo) {
return core::pathUtils::isPathInRootPath(p, libraryInfo.rootDirectory, &excludeDirFileName);
}))
const auto isInActiveLibrary{ [&](const MediaLibraryInfo& lib) {
if (!core::pathUtils::isPathInRootPath(p, lib.rootDirectory))
return false;
return lib.ignoreRules.isEmpty() || !lib.ignoreRules.isIgnored(std::filesystem::relative(p, lib.rootDirectory), IgnoreRules::IsDirectory{ false });
} };
if (std::none_of(std::cbegin(_settings.mediaLibraries), std::cend(_settings.mediaLibraries), isInActiveLibrary))
{
LMS_LOG(DBUPDATER, DEBUG, "Removing " << p << ": out of media directory");
return false;
@@ -33,6 +33,7 @@
#include "scanners/IFileScanner.hpp"
#include "FileScanners.hpp"
#include "IgnoreRules.hpp"
#include "JobQueue.hpp"
#include "ScanContext.hpp"
@@ -41,7 +42,9 @@ namespace lms::scanner
namespace
{
using ExploreFileCallback = std::function<bool(std::error_code, const std::filesystem::path& path, const std::filesystem::directory_entry*)>;
bool exploreFilesRecursive(const std::filesystem::path& directory, ExploreFileCallback cb, const std::filesystem::path* excludeDirFileName)
using ShouldIgnoreCallback = std::function<bool(const std::filesystem::path& absPath, IgnoreRules::IsDirectory)>;
bool exploreFilesRecursive(const std::filesystem::path& directory, const ExploreFileCallback& cb, const ShouldIgnoreCallback& shouldIgnore)
{
std::error_code ec;
std::filesystem::directory_iterator itPath{ directory, std::filesystem::directory_options::follow_directory_symlink, ec };
@@ -52,16 +55,6 @@ namespace lms::scanner
return true; // try to continue exploring anyway
}
if (excludeDirFileName && !excludeDirFileName->empty())
{
const std::filesystem::path excludePath{ directory / *excludeDirFileName };
if (std::filesystem::exists(excludePath, ec))
{
LMS_LOG(DBUPDATER, DEBUG, "Found " << excludePath << ": skipping directory");
return true;
}
}
std::filesystem::directory_iterator itEnd;
while (itPath != itEnd)
{
@@ -74,12 +67,25 @@ namespace lms::scanner
{
continueExploring = cb(ec, path, nullptr);
}
else
else if (entry.is_regular_file())
{
if (entry.is_regular_file())
continueExploring = cb(ec, path, &entry);
else if (entry.is_directory())
continueExploring = exploreFilesRecursive(path, cb, excludeDirFileName);
if (shouldIgnore(path, IgnoreRules::IsDirectory{ false }))
{
LMS_LOG(DBUPDATER, DEBUG, "Ignoring file " << path << " (matched .lmsignore rule)");
itPath.increment(ec);
continue;
}
continueExploring = cb(ec, path, &entry);
}
else if (entry.is_directory())
{
if (shouldIgnore(path, IgnoreRules::IsDirectory{ true }))
{
LMS_LOG(DBUPDATER, DEBUG, "Ignoring directory " << path << " (matched .lmsignore rule)");
itPath.increment(ec);
continue;
}
continueExploring = exploreFilesRecursive(path, cb, shouldIgnore);
}
if (!continueExploring)
@@ -208,7 +214,8 @@ namespace lms::scanner
std::vector<std::filesystem::directory_entry> filesToScan;
exploreFilesRecursive(
mediaLibrary.rootDirectory, [&](std::error_code ec, const std::filesystem::path& path, const std::filesystem::directory_entry* fileEntry) {
mediaLibrary.rootDirectory,
[&](std::error_code ec, const std::filesystem::path& path, const std::filesystem::directory_entry* fileEntry) {
LMS_SCOPED_TRACE_DETAILED("Scanner", "OnExploreFile");
assert((ec && !fileEntry) || (!ec && fileEntry));
@@ -234,7 +241,9 @@ namespace lms::scanner
return true;
},
&excludeDirFileName);
[&](const std::filesystem::path& path, IgnoreRules::IsDirectory isDir) {
return !mediaLibrary.ignoreRules.isEmpty() && mediaLibrary.ignoreRules.isIgnored(std::filesystem::relative(path, mediaLibrary.rootDirectory), isDir);
});
if (!filesToScan.empty())
queue.push(std::make_unique<FileScanJob>(getFileScanners(), mediaLibrary, context.scanOptions.fullScan, filesToScan));
@@ -3,6 +3,7 @@ include(GoogleTest)
add_executable(test-scanner
ArtistInfo.cpp
AudioFileUtils.cpp
IgnoreFilter.cpp
Lyrics.cpp
PlayList.cpp
ScannerStats.cpp
@@ -0,0 +1,172 @@
/*
* Copyright (C) 2025 Emeric Poupon
*
* This file is part of LMS.
*
* LMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* LMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with LMS. If not, see <http://www.gnu.org/licenses/>.
*/
#include <gtest/gtest.h>
#include "IgnoreRules.hpp"
namespace lms::scanner::tests
{
TEST(IgnoreRules, EmptyContent)
{
const IgnoreRules f{ "" };
EXPECT_TRUE(f.isEmpty());
EXPECT_FALSE(f.isIgnored("track.flac", IgnoreRules::IsDirectory{ false }));
}
TEST(IgnoreRules, CommentsAndBlanksOnly)
{
const IgnoreRules f{ "# this is a comment\n\n# another comment\n" };
EXPECT_TRUE(f.isEmpty());
}
TEST(IgnoreRules, Basename_MatchesAtRoot)
{
const IgnoreRules f{ "*.nfo\n" };
EXPECT_TRUE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("track.flac", IgnoreRules::IsDirectory{ false }));
}
TEST(IgnoreRules, Basename_MatchesInSubdir)
{
const IgnoreRules f{ "*.nfo\n" };
EXPECT_TRUE(f.isIgnored("jazz/miles/track.nfo", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("jazz/miles/track.flac", IgnoreRules::IsDirectory{ false }));
}
TEST(IgnoreRules, Basename_QuestionMark)
{
const IgnoreRules f{ "?.nfo\n" };
EXPECT_TRUE(f.isIgnored("a.nfo", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("ab.nfo", IgnoreRules::IsDirectory{ false }));
}
TEST(IgnoreRules, Basename_ExactName)
{
const IgnoreRules f{ "Thumbs.db\n" };
EXPECT_TRUE(f.isIgnored("Thumbs.db", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("thumbs.db", IgnoreRules::IsDirectory{ false })); // case-sensitive
}
TEST(IgnoreRules, Basename_MultipleWildcards)
{
const IgnoreRules f{ "cover*.*\n" };
EXPECT_TRUE(f.isIgnored("cover.jpg", IgnoreRules::IsDirectory{ false }));
EXPECT_TRUE(f.isIgnored("cover_front.jpg", IgnoreRules::IsDirectory{ false }));
EXPECT_TRUE(f.isIgnored("cover.png", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("notcover.jpg", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("coverjpg", IgnoreRules::IsDirectory{ false })); // no dot
}
TEST(IgnoreRules, Basename_WildcardInDirName)
{
const IgnoreRules f{ "covers*/\n" };
EXPECT_TRUE(f.isIgnored("covers", IgnoreRules::IsDirectory{ true }));
EXPECT_TRUE(f.isIgnored("covers_2024", IgnoreRules::IsDirectory{ true }));
EXPECT_TRUE(f.isIgnored("jazz/covers_hq", IgnoreRules::IsDirectory{ true })); // unanchored — matches at any depth
EXPECT_FALSE(f.isIgnored("notcovers", IgnoreRules::IsDirectory{ true }));
EXPECT_FALSE(f.isIgnored("covers_2024", IgnoreRules::IsDirectory{ false })); // dirOnly
}
TEST(IgnoreRules, FullPath_WildcardInPathComponent)
{
const IgnoreRules f{ "jazz/*/liner.nfo\n" };
EXPECT_TRUE(f.isIgnored("jazz/miles/liner.nfo", IgnoreRules::IsDirectory{ false }));
EXPECT_TRUE(f.isIgnored("jazz/coltrane/liner.nfo", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("jazz/liner.nfo", IgnoreRules::IsDirectory{ false })); // no intermediate component
EXPECT_FALSE(f.isIgnored("jazz/miles/davis/liner.nfo", IgnoreRules::IsDirectory{ false })); // * doesn't cross /
EXPECT_FALSE(f.isIgnored("rock/miles/liner.nfo", IgnoreRules::IsDirectory{ false }));
}
TEST(IgnoreRules, Basename_MatchesDirectoryToo)
{
// patterns without trailing / apply to both files and directories
const IgnoreRules f{ "*.nfo\n" };
EXPECT_TRUE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ false }));
EXPECT_TRUE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ true }));
}
TEST(IgnoreRules, DirOnly_IgnoresDirectory)
{
const IgnoreRules f{ "covers/\n" };
EXPECT_TRUE(f.isIgnored("covers", IgnoreRules::IsDirectory{ true }));
EXPECT_FALSE(f.isIgnored("covers", IgnoreRules::IsDirectory{ false })); // not a dir
}
TEST(IgnoreRules, DirOnly_AnchoredOnlyMatchesRoot)
{
const IgnoreRules f{ "/untagged/\n" };
EXPECT_TRUE(f.isIgnored("untagged", IgnoreRules::IsDirectory{ true })); // root level — match
EXPECT_FALSE(f.isIgnored("jazz/untagged", IgnoreRules::IsDirectory{ true })); // nested — no match
EXPECT_FALSE(f.isIgnored("untagged", IgnoreRules::IsDirectory{ false })); // file, not dir — no match
}
TEST(IgnoreRules, DirOnly_UnanchoredMatchesAnyDepth)
{
const IgnoreRules f{ "covers/\n" };
EXPECT_TRUE(f.isIgnored("covers", IgnoreRules::IsDirectory{ true })); // root — match
EXPECT_TRUE(f.isIgnored("jazz/covers", IgnoreRules::IsDirectory{ true })); // nested — also match
EXPECT_TRUE(f.isIgnored("a/b/c/covers", IgnoreRules::IsDirectory{ true })); // deep — also match
EXPECT_FALSE(f.isIgnored("covers", IgnoreRules::IsDirectory{ false })); // file, not dir — no match
}
TEST(IgnoreRules, FullPath_ExactDir)
{
const IgnoreRules f{ "jazz/covers\n" };
EXPECT_TRUE(f.isIgnored("jazz/covers", IgnoreRules::IsDirectory{ true }));
EXPECT_FALSE(f.isIgnored("jazz/covers/foo", IgnoreRules::IsDirectory{ true })); // never reached in practice: scanner prunes jazz/covers/ first
EXPECT_FALSE(f.isIgnored("foo/jazz/covers", IgnoreRules::IsDirectory{ true })); // never reached in practice: scanner prunes jazz/covers/ first
EXPECT_FALSE(f.isIgnored("rock/covers", IgnoreRules::IsDirectory{ true }));
}
TEST(IgnoreRules, FullPath_GlobInDir)
{
const IgnoreRules f{ "jazz/*.nfo\n" };
EXPECT_TRUE(f.isIgnored("jazz/liner.nfo", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("liner.nfo", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("rock/liner.nfo", IgnoreRules::IsDirectory{ false }));
}
TEST(IgnoreRules, Negation_ReIncludesAfterBroadMatch)
{
const IgnoreRules f{ "*.nfo\n!important.nfo\n" };
EXPECT_TRUE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("important.nfo", IgnoreRules::IsDirectory{ false }));
}
TEST(IgnoreRules, Negation_NoEffectIfNoPriorMatch)
{
const IgnoreRules f{ "!track.nfo\n" };
EXPECT_FALSE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ false }));
}
TEST(IgnoreRules, LastRuleWins)
{
const IgnoreRules f{ "*.flac\n!keep.flac\n*.flac\n" };
EXPECT_TRUE(f.isIgnored("keep.flac", IgnoreRules::IsDirectory{ false }));
}
TEST(IgnoreRules, WindowsLineEndings)
{
const IgnoreRules f{ "*.nfo\r\n*.jpg\r\n" };
EXPECT_TRUE(f.isIgnored("track.nfo", IgnoreRules::IsDirectory{ false }));
EXPECT_TRUE(f.isIgnored("cover.jpg", IgnoreRules::IsDirectory{ false }));
EXPECT_FALSE(f.isIgnored("track.flac", IgnoreRules::IsDirectory{ false }));
}
} // namespace lms::scanner::tests