diff --git a/src/main/java/org/eolang/lints/ThrottledLint.java b/src/main/java/org/eolang/lints/ThrottledLint.java new file mode 100644 index 000000000..d610f2600 --- /dev/null +++ b/src/main/java/org/eolang/lints/ThrottledLint.java @@ -0,0 +1,122 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com + * SPDX-License-Identifier: MIT + */ +package org.eolang.lints; + +import com.jcabi.xml.XML; +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import java.util.concurrent.Callable; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Throttled lint decorator. + *

+ * Wraps a lint and limits its execution time to 10 seconds. + * If the lint takes longer than the timeout, it is interrupted + * and a single timeout defect is returned. + *

+ * @since 0.0.47 + */ +public final class ThrottledLint implements Lint { + + /** + * Timeout in seconds. + */ + private static final int TIMEOUT = 10; + + /** + * The lint to throttle. + */ + private final Lint decorated; + + /** + * Ctor. + * @param lnt The lint to decorate + */ + public ThrottledLint(final Lint lnt) { + this.decorated = lnt; + } + + @Override + public String name() { + return this.decorated.name(); + } + + @Override + public Collection defects(final XML xmir) throws IOException { + final ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + final Future> future = executor.submit( + new Callable>() { + @Override + public Collection call() { + try { + return ThrottledLint.this.decorated.defects(xmir); + } catch (final IOException ex) { + throw new RuntimeException(ex); + } + } + } + ); + try { + return future.get(ThrottledLint.TIMEOUT, TimeUnit.SECONDS); + } catch (final TimeoutException ex) { + future.cancel(true); + return Collections.singletonList( + new Defect.Default( + this.name(), + Severity.WARNING, + 0, + String.format( + "Lint '%s' exceeded %d second timeout", + this.name(), + ThrottledLint.TIMEOUT + ) + ) + ); + } catch (final ExecutionException ex) { + final Throwable cause = ex.getCause(); + if (cause instanceof IOException) { + throw (IOException) cause; + } + throw new IOException( + String.format( + "Lint '%s' failed with error: %s", + this.name(), + cause.getMessage() + ), + cause + ); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + throw new IOException( + String.format( + "Lint '%s' was interrupted", + this.name() + ), + ex + ); + } + } finally { + executor.shutdownNow(); + } + } + + @Override + public String motive() throws IOException { + return this.decorated.motive(); + } + + @Override + public Fix fix() { + return this.decorated.fix(); + } +} diff --git a/src/test/java/org/eolang/lints/ThrottledLintTest.java b/src/test/java/org/eolang/lints/ThrottledLintTest.java new file mode 100644 index 000000000..161868c24 --- /dev/null +++ b/src/test/java/org/eolang/lints/ThrottledLintTest.java @@ -0,0 +1,133 @@ +/* + * SPDX-FileCopyrightText: Copyright (c) 2016-2026 Objectionary.com + * SPDX-License-Identifier: MIT + */ +package org.eolang.lints; + +import com.jcabi.xml.XML; +import fixtures.EoProgram; +import java.io.IOException; +import java.util.Collection; +import java.util.Collections; +import org.hamcrest.MatcherAssert; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.Test; + +/** + * Test for {@link ThrottledLint}. + * @since 0.0.47 + */ +final class ThrottledLintTest { + + @Test + void forwardsName() { + MatcherAssert.assertThat( + "ThrottledLint should forward the wrapped lint's name", + new ThrottledLint(new LtAlways()).name(), + Matchers.equalTo("always") + ); + } + + @Test + void returnsDefectsFromWrappedLint() throws IOException { + final Collection defects = new ThrottledLint(new LtAlways()).defects( + new EoProgram("org/eolang/lints/foo-without-dot.eo").parse() + ); + MatcherAssert.assertThat( + "ThrottledLint should return defects from the wrapped lint", + defects, + Matchers.hasSize(1) + ); + } + + @Test + void returnsTimeoutDefectWhenLintTakesTooLong() throws IOException { + final Lint slow = new Lint() { + @Override + public String name() { + return "slow-lint"; + } + + @Override + public Collection defects(final XML xmir) throws IOException { + try { + Thread.sleep(20_000); + } catch (final InterruptedException ex) { + Thread.currentThread().interrupt(); + } + return Collections.emptyList(); + } + + @Override + public String motive() throws IOException { + return "A slow lint for testing"; + } + + @Override + public Fix fix() { + return new FxEmpty(); + } + }; + final Collection defects = new ThrottledLint(slow).defects( + new EoProgram("org/eolang/lints/foo-without-dot.eo").parse() + ); + MatcherAssert.assertThat( + "ThrottledLint should return a single timeout defect when lint exceeds 10s", + defects, + Matchers.hasSize(1) + ); + final Defect defect = defects.iterator().next(); + MatcherAssert.assertThat( + "Timeout defect should have WARNING severity", + defect.severity(), + Matchers.equalTo(Severity.WARNING) + ); + MatcherAssert.assertThat( + "Timeout defect should mention the timeout", + defect.text(), + Matchers.containsString("timeout") + ); + } + + @Test + void returnsEmptyWhenWrappedLintReturnsEmpty() throws IOException { + final Lint empty = new Lint() { + @Override + public String name() { + return "empty-lint"; + } + + @Override + public Collection defects(final XML xmir) throws IOException { + return Collections.emptyList(); + } + + @Override + public String motive() throws IOException { + return "A lint that returns nothing"; + } + + @Override + public Fix fix() { + return new FxEmpty(); + } + }; + final Collection defects = new ThrottledLint(empty).defects( + new EoProgram("org/eolang/lints/foo-without-dot.eo").parse() + ); + MatcherAssert.assertThat( + "ThrottledLint should return empty when wrapped lint returns empty", + defects, + Matchers.hasSize(0) + ); + } + + @Test + void delegatesFix() { + MatcherAssert.assertThat( + "ThrottledLint should delegate fix() to wrapped lint", + new ThrottledLint(new LtAlways()).fix(), + Matchers.notNullValue() + ); + } +}