diff --git a/src/main/java/org/eolang/lints/LtMystery.java b/src/main/java/org/eolang/lints/LtMystery.java new file mode 100644 index 000000000..9c6aeac2e --- /dev/null +++ b/src/main/java/org/eolang/lints/LtMystery.java @@ -0,0 +1,163 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com + * SPDX-License-Identifier: MIT + */ +package org.eolang.lints; + +import com.github.lombrozo.xnav.Xnav; +import com.jcabi.xml.XML; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Lint to catch mystery objects. + * + *

A mystery object is a free application of an object that is neither + * declared anywhere in the program nor is one of the {@code org.eolang} + * prime objects (like {@code number} or {@code bytes}). It almost always + * means a typo in the name of an object or a missing {@code +alias} meta. + * Such a call is compiled to {@code Φ.NAME} reference, which this lint + * inspects.

+ * + * @since 0.0.52 + */ +final class LtMystery implements Lint { + + /** + * Reserved names. + * The key is object name, the value is the path to EO file. + */ + private final Map reserved; + + /** + * Ctor. + */ + LtMystery() { + this(new ReservedNames()); + } + + /** + * Ctor. + * @param names Reserved names + */ + LtMystery(final Map names) { + this.reserved = names; + } + + @Override + public String name() { + return "mystery-object"; + } + + @Override + public Collection defects(final XML xmir) throws IOException { + final Collection defects; + if (this.reserved.isEmpty()) { + defects = new ArrayList<>(0); + } else { + defects = LtMystery.find(xmir, this.reserved); + } + return defects; + } + + @Override + public String motive() throws IOException { + return new MotiveFrom("names", this.name()).asString(); + } + + @Override + public Fix fix() { + return new FxEmpty(); + } + + /** + * Detect mystery objects in the program. + * @param xmir XMIR document + * @param reserved Reserved org.eolang names + * @return Detected defects + */ + private static Collection find(final XML xmir, + final Map reserved) { + final Xnav xnav = new Xnav(xmir.inner()); + final Set declared = xnav.path("//o[@name]").map( + elem -> elem.attribute("name").text().get() + ).collect(Collectors.toSet()); + return xnav.path("//o[@base]").filter( + LtMystery::simpleBase + ).filter( + elem -> LtMystery.unknown(elem, declared, reserved) + ).map( + LtMystery::defect + ).collect(Collectors.toList()); + } + + /** + * Is the {@code @base} a bare single-object reference? + * @param elem Object element + * @return True if it's a simple object reference + */ + private static boolean simpleBase(final Xnav elem) { + final Optional base = elem.attribute("base").text(); + return base.isPresent() + && LtMystery.simple(base.get()); + } + + /** + * Is the referenced object unknown? + * The name is the {@code @base} value without the leading two characters, + * which are the {@code Φ.} prefix. + * @param elem Object element + * @param declared Objects declared in the program + * @param reserved Reserved org.eolang names + * @return True if the object is neither declared nor reserved + */ + private static boolean unknown(final Xnav elem, + final Set declared, final Map reserved) { + final String name = LtMystery.objectName(elem); + return !declared.contains(name) + && !reserved.containsKey(name); + } + + /** + * Object name from the {@code @base} attribute. + * The name is the value without the leading two characters, + * which are the {@code Φ.} prefix. + * @param elem Object element + * @return Object name + */ + private static String objectName(final Xnav elem) { + return elem.attribute("base").text().get().substring(2); + } + + /** + * Defect for an unknown object. + * @param elem Object element + * @return Defect + */ + private static Defect defect(final Xnav elem) { + return new Defect.Default( + "mystery-object", + Severity.ERROR, + new LineOf(elem).value(), + String.format( + "Object \"%s\" is not defined in the program and is not part of org.eolang", + LtMystery.objectName(elem) + ) + ); + } + + /** + * Check if the base is a reference to a single object, like + * {@code Φ.bar}, and not to a path like {@code Φ.org.eolang.io.stdout}. + * @param base Base attribute value + * @return True if it's a simple object reference + */ + private static boolean simple(final String base) { + return base.matches("^Φ\\.[a-z][a-z0-9_-]*$"); + } +} diff --git a/src/main/java/org/eolang/lints/MonoLints.java b/src/main/java/org/eolang/lints/MonoLints.java index 1ce9c4bb9..246a149db 100644 --- a/src/main/java/org/eolang/lints/MonoLints.java +++ b/src/main/java/org/eolang/lints/MonoLints.java @@ -27,7 +27,8 @@ final class MonoLints extends IterableEnvelope { List.of( new LtAsciiOnly(), new LtReservedName(), - new LtSyntaxVersion() + new LtSyntaxVersion(), + new LtMystery() ) ) ); diff --git a/src/main/resources/org/eolang/motives/names/mystery-object.md b/src/main/resources/org/eolang/motives/names/mystery-object.md new file mode 100644 index 000000000..d6677ee5c --- /dev/null +++ b/src/main/resources/org/eolang/motives/names/mystery-object.md @@ -0,0 +1,20 @@ +# Mystery object + +Objects, applied in the code, must be defined somewhere in the program, +be the objects of `org.eolang.*` or be imported via `+alias` meta. Free +usage of an unknown object is a mystery object—it is almost always a +typo in the name, since this object is not declared anywhere. + +Incorrect: + +```eo +# Foo. +[] > foo + bar 42 > x +``` + +Here, `bar` is not defined in the program, not an object from `org.eolang.*` +and not imported via `+alias`. It will be compiled to `Φ.bar` reference, +which can't be validated by the parser. This is what we call a mystery +object, and it should be fixed by adding the definition of `bar`, importing +it, or using a proper object name. diff --git a/src/test/java/org/eolang/lints/LtMysteryTest.java b/src/test/java/org/eolang/lints/LtMysteryTest.java new file mode 100644 index 000000000..850b9481c --- /dev/null +++ b/src/test/java/org/eolang/lints/LtMysteryTest.java @@ -0,0 +1,121 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com + * SPDX-License-Identifier: MIT + */ +package org.eolang.lints; + +import fixtures.EoProgram; +import java.io.IOException; +import java.util.Map; +import org.cactoos.io.InputOf; +import org.cactoos.list.ListOf; +import org.cactoos.map.MapEntry; +import org.cactoos.map.MapOf; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link LtMystery}. + * @since 0.0.52 + */ +final class LtMysteryTest { + + @Test + void catchesMysteryObject() throws IOException { + MatcherAssert.assertThat( + "It is expected to catch a mystery object here", + new LtMystery(this.canonical()).defects( + new EoProgram("org/eolang/lints/mystery-object.eo").parse() + ), + Matchers.hasSize(1) + ); + } + + @Test + void allowsDeclaredObject() throws IOException { + MatcherAssert.assertThat( + "Defects are not empty, but the object is declared in the program", + new LtMystery(this.canonical()).defects( + new EoProgram("org/eolang/lints/declared-object.eo").parse() + ), + Matchers.emptyIterable() + ); + } + + @Test + void allowsAliasedObject() throws IOException { + MatcherAssert.assertThat( + "Defects are not empty, but the object is imported via +alias", + new LtMystery(this.canonical()).defects( + new EoProgram("org/eolang/lints/aliased-object.eo").parse() + ), + Matchers.emptyIterable() + ); + } + + @Test + void allowsCanonicalObject() throws IOException { + MatcherAssert.assertThat( + "Defects are not empty, but the object is part of org.eolang", + new LtMystery(this.canonical()).defects( + new EoProgram("org/eolang/lints/canonical-object.eo").parse() + ), + Matchers.emptyIterable() + ); + } + + @Test + void reportsCorrectMessageForMysteryObject() throws IOException { + MatcherAssert.assertThat( + "The message should mention the mystery object", + new ListOf<>( + new LtMystery(this.canonical()).defects( + new EoProgram("org/eolang/lints/mystery-object.eo").parse() + ) + ).get(0).text(), + Matchers.equalTo( + "Object \"bar\" is not defined in the program and is not part of org.eolang" + ) + ); + } + + @Test + void allowsAllObjectsDeclaredInline() throws IOException { + final String src = String.format( + "[] > foo%n [a] > bar%n bar > x" + ); + MatcherAssert.assertThat( + "Objects should not be reported, since all of them are declared", + new LtMystery(this.canonical()).defects( + new EoProgram(src, new InputOf(src)).parse() + ), + Matchers.emptyIterable() + ); + } + + @Tag("reserved") + @Test + void scansMysteryFromHome() throws Exception { + MatcherAssert.assertThat( + "It is expected to catch a mystery object using reserved names from home", + new LtMystery().defects( + new EoProgram("org/eolang/lints/mystery-bipki.eo").parse() + ), + Matchers.hasSize(1) + ); + } + + /** + * Dummy reserved names for the tests. + * @return Reserved names map + */ + private Map canonical() { + return new MapOf<>( + new MapEntry<>("number", "number.eo"), + new MapEntry<>("bytes", "bytes.eo"), + new MapEntry<>("string", "string.eo") + ); + } +} diff --git a/src/test/resources/org/eolang/lints/aliased-object.eo b/src/test/resources/org/eolang/lints/aliased-object.eo new file mode 100644 index 000000000..bc7dc8c08 --- /dev/null +++ b/src/test/resources/org/eolang/lints/aliased-object.eo @@ -0,0 +1,7 @@ ++spdx SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com ++spdx SPDX-License-Identifier: MIT ++alias org.eolang.io.stdout + +# Foo. +[] > foo + stdout "Hello" > x diff --git a/src/test/resources/org/eolang/lints/canonical-object.eo b/src/test/resources/org/eolang/lints/canonical-object.eo new file mode 100644 index 000000000..6c0ae5309 --- /dev/null +++ b/src/test/resources/org/eolang/lints/canonical-object.eo @@ -0,0 +1,6 @@ ++spdx SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com ++spdx SPDX-License-Identifier: MIT + +# Foo. +[] > foo + 42 > x diff --git a/src/test/resources/org/eolang/lints/declared-object.eo b/src/test/resources/org/eolang/lints/declared-object.eo new file mode 100644 index 000000000..520cdcb12 --- /dev/null +++ b/src/test/resources/org/eolang/lints/declared-object.eo @@ -0,0 +1,7 @@ ++spdx SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com ++spdx SPDX-License-Identifier: MIT + +# Foo. +[] > foo + [t] > bar + bar 42 > x diff --git a/src/test/resources/org/eolang/lints/mystery-bipki.eo b/src/test/resources/org/eolang/lints/mystery-bipki.eo new file mode 100644 index 000000000..1e7f67a9b --- /dev/null +++ b/src/test/resources/org/eolang/lints/mystery-bipki.eo @@ -0,0 +1,6 @@ ++spdx SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com ++spdx SPDX-License-Identifier: MIT + +# Foo. +[] > foo + bipki 42 > x \ No newline at end of file diff --git a/src/test/resources/org/eolang/lints/mystery-object.eo b/src/test/resources/org/eolang/lints/mystery-object.eo new file mode 100644 index 000000000..c00d8c7ae --- /dev/null +++ b/src/test/resources/org/eolang/lints/mystery-object.eo @@ -0,0 +1,6 @@ ++spdx SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com ++spdx SPDX-License-Identifier: MIT + +# Foo. +[] > foo + bar 42 > x