diff --git a/src/main/java/org/eolang/lints/LtSyntaxVersion.java b/src/main/java/org/eolang/lints/LtSyntaxVersion.java new file mode 100644 index 000000000..685c3b96a --- /dev/null +++ b/src/main/java/org/eolang/lints/LtSyntaxVersion.java @@ -0,0 +1,160 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com + * SPDX-License-Identifier: MIT + */ +package org.eolang.lints; + +import com.github.lombrozo.xnav.Xnav; +import com.google.common.base.Splitter; +import com.jcabi.xml.XML; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; +import org.cactoos.io.ResourceOf; +import org.cactoos.text.IoCheckedText; +import org.cactoos.text.TextOf; +import org.eolang.parser.OnDefault; + +/** + * Checks that the version specified in the +syntax meta is not + * newer than the current parser version. + * + *

If the +syntax meta specifies a version higher than the parser's + * version, it means the code requires a newer parser, and this + * lint reports an error.

+ * + * @since 0.1.0 + */ +final class LtSyntaxVersion implements Lint { + + /** + * The parser version. + */ + private final String parser; + + /** + * Default ctor. + */ + LtSyntaxVersion() { + this("0.0.0"); + } + + /** + * Ctor. + * @param ver The parser version (must be valid SemVer). + */ + @SuppressWarnings("PMD.ConstructorOnlyInitializesOrCallOtherConstructors") + LtSyntaxVersion(final String ver) { + if (ver == null || !LtSyntaxVersion.valid(ver)) { + throw new IllegalArgumentException( + String.format( + "parser version must be valid SemVer (e.g. 1.2.3), got: %s", + Optional.ofNullable(ver) + .map(v -> String.format("\"%s\"", v)) + .orElse("null") + ) + ); + } + this.parser = ver; + } + + @Override + public Collection defects(final XML xmir) throws IOException { + final Collection defects = new ArrayList<>(0); + final Xnav xml = new Xnav(xmir.inner()); + final List metas = xml.path("/object/metas/meta[head='syntax']") + .collect(Collectors.toList()); + for (final Xnav meta : metas) { + final String tail = meta.element("tail").text().orElse(""); + final String line = meta.attribute("line").text().orElse("0"); + if (LtSyntaxVersion.valid(tail)) { + if (this.newer(tail)) { + defects.add( + new Defect.Default( + this.name(), + Severity.ERROR, + new OnDefault(xmir).get(), + Integer.parseInt(line), + String.format( + "The +syntax meta requires version %s, but the current parser version is %s (older)", + tail, + this.parser + ) + ) + ); + } + } else { + defects.add( + new Defect.Default( + this.name(), + Severity.ERROR, + new OnDefault(xmir).get(), + Integer.parseInt(line), + String.format( + "The format of the +syntax meta is wrong: %s (SemVer expected instead)", + tail + ) + ) + ); + } + } + return defects; + } + + @Override + public String name() { + return "syntax-version-mismatch"; + } + + @Override + public String motive() throws IOException { + return new IoCheckedText( + new TextOf( + new ResourceOf( + "org/eolang/motives/metas/syntax-version-mismatch.md" + ) + ) + ).asString(); + } + + /** + * Check if the version string is a valid SemVer. + * @param ver The version to validate. + * @return True if valid. + */ + private static boolean valid(final String ver) { + return ver.matches("^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9-]+)?$"); + } + + /** + * Check if the syntax version is newer than the parser version. + * @param syntax The version from +syntax meta. + * @return True if parser version is older than syntax version. + */ + private boolean newer(final String syntax) { + return Arrays.compare( + LtSyntaxVersion.parts(this.parser), + LtSyntaxVersion.parts(syntax) + ) < 0; + } + + /** + * Parse the numeric parts of a SemVer string. + * @param ver The version string. + * @return Array of major, minor, patch. + */ + private static int[] parts(final String ver) { + final List segments = Splitter.on('.').splitToList( + ver.split("-", 2)[0] + ); + return new int[]{ + Integer.parseInt(segments.get(0)), + Integer.parseInt(segments.get(1)), + Integer.parseInt(segments.get(2)), + }; + } +} diff --git a/src/main/java/org/eolang/lints/MonoLints.java b/src/main/java/org/eolang/lints/MonoLints.java index b4d814d17..d3c6a6908 100644 --- a/src/main/java/org/eolang/lints/MonoLints.java +++ b/src/main/java/org/eolang/lints/MonoLints.java @@ -31,7 +31,8 @@ final class MonoLints extends IterableEnvelope> { new PkByXsl(), List.of( new LtAsciiOnly(), - new LtReservedName() + new LtReservedName(), + new LtSyntaxVersion() ) ) ); diff --git a/src/main/resources/org/eolang/lints/metas/unique-metas.xsl b/src/main/resources/org/eolang/lints/metas/unique-metas.xsl index 4d2e64553..26cf6218a 100644 --- a/src/main/resources/org/eolang/lints/metas/unique-metas.xsl +++ b/src/main/resources/org/eolang/lints/metas/unique-metas.xsl @@ -7,7 +7,7 @@ - + diff --git a/src/main/resources/org/eolang/lints/metas/unknown-metas.xsl b/src/main/resources/org/eolang/lints/metas/unknown-metas.xsl index 7b8bb02e0..0cf285ac9 100644 --- a/src/main/resources/org/eolang/lints/metas/unknown-metas.xsl +++ b/src/main/resources/org/eolang/lints/metas/unknown-metas.xsl @@ -12,7 +12,7 @@ - + diff --git a/src/main/resources/org/eolang/motives/metas/syntax-version-mismatch.md b/src/main/resources/org/eolang/motives/metas/syntax-version-mismatch.md new file mode 100644 index 000000000..c0e7b9a8c --- /dev/null +++ b/src/main/resources/org/eolang/motives/metas/syntax-version-mismatch.md @@ -0,0 +1,24 @@ +# Syntax version mismatch + +The `+syntax` meta specifies the version of the EO language that +the source code was written for. If the parser version is older +than the version specified in `+syntax`, the code may use features +that are not supported by the parser, leading to compilation errors. + +The parser will refuse to process such files. + +Incorrect (if parser version is 0.58.0): + +```eo ++syntax 0.59.0 + +[] > foo +``` + +Correct (if parser version is 0.59.0 or newer): + +```eo ++syntax 0.59.0 + +[] > foo +``` diff --git a/src/main/resources/org/eolang/motives/metas/unknown-metas.md b/src/main/resources/org/eolang/motives/metas/unknown-metas.md index 0ee1a4d3d..ef58e2a9b 100644 --- a/src/main/resources/org/eolang/motives/metas/unknown-metas.md +++ b/src/main/resources/org/eolang/motives/metas/unknown-metas.md @@ -10,6 +10,7 @@ The following metas are supported: * `+home` * `+unlint` * `+probe` +* `+syntax` Incorrect: @@ -29,6 +30,7 @@ Correct: +architect yegor256@gmail.com +rt jvm +home https://earth.com ++syntax 0.59.0 +unlint unsorted-metas [] > foo diff --git a/src/test/java/org/eolang/lints/LtSyntaxVersionTest.java b/src/test/java/org/eolang/lints/LtSyntaxVersionTest.java new file mode 100644 index 000000000..b1bface0f --- /dev/null +++ b/src/test/java/org/eolang/lints/LtSyntaxVersionTest.java @@ -0,0 +1,175 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com + * SPDX-License-Identifier: MIT + */ +package org.eolang.lints; + +import java.io.IOException; +import org.eolang.parser.EoSyntax; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +/** + * Test for {@link LtSyntaxVersion}. + * + * @since 0.1.0 + */ +@SuppressWarnings("PMD.TooManyMethods") +final class LtSyntaxVersionTest { + + /** + * Common version used in tests. + */ + private static final String VER = "0.59.0"; + + @Test + void catchesMismatchWhenSyntaxIsNewer() throws IOException { + MatcherAssert.assertThat( + "should report error when +syntax version is newer than parser", + new LtSyntaxVersion("0.58.0").defects( + new EoSyntax( + String.format("+syntax %s\n\n# Foo.\n[] > foo\n", LtSyntaxVersionTest.VER) + ).parsed() + ), + Matchers.iterableWithSize(1) + ); + } + + @Test + void allowsWhenParserIsNewer() throws IOException { + MatcherAssert.assertThat( + "should not report error when parser version is newer", + new LtSyntaxVersion("0.60.0").defects( + new EoSyntax( + String.format("+syntax %s\n\n# Foo.\n[] > foo\n", LtSyntaxVersionTest.VER) + ).parsed() + ), + Matchers.emptyIterable() + ); + } + + @Test + void allowsWhenVersionsMatch() throws IOException { + MatcherAssert.assertThat( + "should not report error when versions match exactly", + new LtSyntaxVersion(LtSyntaxVersionTest.VER).defects( + new EoSyntax( + String.format("+syntax %s\n\n# Foo.\n[] > foo\n", LtSyntaxVersionTest.VER) + ).parsed() + ), + Matchers.emptyIterable() + ); + } + + @Test + void allowsMatchingDefaultVersion() throws IOException { + MatcherAssert.assertThat( + "with default ctor (0.0.0), +syntax 0.0.0 should not report error", + new LtSyntaxVersion().defects( + new EoSyntax( + "+syntax 0.0.0\n\n# Foo.\n[] > foo\n" + ).parsed() + ), + Matchers.emptyIterable() + ); + } + + @Test + void catchesNewerThanDefaultVersion() throws IOException { + MatcherAssert.assertThat( + "with default ctor (0.0.0), +syntax 0.0.1 should report error", + new LtSyntaxVersion().defects( + new EoSyntax( + "+syntax 0.0.1\n\n# Foo.\n[] > foo\n" + ).parsed() + ), + Matchers.iterableWithSize(1) + ); + } + + @Test + void ignoresWhenNoSyntaxMeta() throws IOException { + MatcherAssert.assertThat( + "should not report error when no +syntax meta is present", + new LtSyntaxVersion(LtSyntaxVersionTest.VER).defects( + new EoSyntax( + "# Foo.\n[] > foo\n" + ).parsed() + ), + Matchers.emptyIterable() + ); + } + + @Test + void reportsErrorOnInvalidSyntaxFormat() throws IOException { + MatcherAssert.assertThat( + "should report error for invalid +syntax format", + new LtSyntaxVersion(LtSyntaxVersionTest.VER).defects( + new EoSyntax( + "+syntax alpha\n\n# Foo.\n[] > foo\n" + ).parsed() + ), + Matchers.iterableWithSize(1) + ); + } + + @Test + void reportsErrorSeverity() throws IOException { + MatcherAssert.assertThat( + "defect should have error severity", + new LtSyntaxVersion("0.58.0").defects( + new EoSyntax( + String.format("+syntax %s\n\n# Foo.\n[] > foo\n", LtSyntaxVersionTest.VER) + ).parsed() + ).iterator().next().severity(), + Matchers.equalTo(Severity.ERROR) + ); + } + + @Test + void catchesMajorVersionMismatch() throws IOException { + MatcherAssert.assertThat( + "should detect when major version is newer", + new LtSyntaxVersion(LtSyntaxVersionTest.VER).defects( + new EoSyntax( + "+syntax 1.0.0\n\n# Foo.\n[] > foo\n" + ).parsed() + ), + Matchers.iterableWithSize(1) + ); + } + + @Test + void catchesPatchVersionMismatch() throws IOException { + MatcherAssert.assertThat( + "should detect when patch version is newer", + new LtSyntaxVersion(LtSyntaxVersionTest.VER).defects( + new EoSyntax( + "+syntax 0.59.1\n\n# Foo.\n[] > foo\n" + ).parsed() + ), + Matchers.iterableWithSize(1) + ); + } + + @Test + void rejectsInvalidParserVersion() { + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new LtSyntaxVersion("latest"), + "should reject non-semver parser version 'latest'" + ); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new LtSyntaxVersion(""), + "should reject empty string as parser version" + ); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> new LtSyntaxVersion(null), + "should reject null as parser version" + ); + } +}