diff --git a/docs/reference/cli.md b/docs/reference/cli.md index bb9895f0d8..2f45492aae 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1148,6 +1148,90 @@ $ nextflow log tiny_leavitt -F 'process =~ /split_letters/' work/1f/f1ea9158fb23b53d5083953121d6b6 ``` +### `logfile` + +:::{versionadded} 26.05.0-edge +::: + +Print the contents of a Nextflow log file, with optional level filtering and follow mode. + +**Usage** + +```console +$ nextflow logfile [options] [run_name | session_id | path] +``` + +**Description** + +The `logfile` command prints the contents of a `.nextflow.log` file written by Nextflow during a pipeline run. The argument can be: + +- A run name (e.g. `kickass_rutherford`) — resolved via the local execution history and matched to the corresponding `.nextflow.log[.N]` file in the current directory by session id. +- A session id (or unique prefix), or the literal `last` for the most recent run. +- A direct path to a log file (e.g. `.nextflow.log.4` or an absolute path). + +If no argument is given, the most recent run is used. + +**Options** + +`-f, -follow` +: Continuously print new lines as the file grows (similar to `tail -f`). Stop with Ctrl+C. + +`-h, -help` +: Print the command usage. + +`-keep-ansi` +: Preserve any ANSI escape sequences found inside log content. By default these are stripped so the output is clean text. + +`-l, -level ` +: Minimum log level to print: `TRACE`, `DEBUG`, `INFO`, `WARN`, or `ERROR`. Case-insensitive. When set, only entries at this level or higher are printed. Multi-line entries (e.g. stack traces) inherit the level of their parent entry. No filtering is applied by default. + +`-n, -lines ` +: Print only the last N log entries. Counts entries (one or more lines each), not raw lines. + +`-no-pager` +: Do not pipe the output through a pager. By default, when standard output is a terminal, the output is piped through `$NXF_PAGER`, falling back to `$PAGER`, falling back to `less -FR`. A bare `less` invocation is augmented with `-FR` so colors render. Short outputs exit without entering the pager. Pager is automatically disabled with `-f`/`-follow`. + +`-no-ansi` +: Disable colored output. By default, when the output destination is a terminal: + + - Every line is prefixed with a coloured 2-character level indicator. + - TRACE and DEBUG entries are dimmed. + - DEBUG, INFO, WARN and ERROR entries are syntax-highlighted to make them easier to read. + + Styling is auto-disabled when output goes to a non-terminal destination (file or pipe with `-no-pager`) or when the `NO_COLOR` environment variable is set. + +**Examples** + +Print the log of the most recent run: + +```console +$ nextflow logfile +``` + +Print the log of a specific run, looked up by name: + +```console +$ nextflow logfile kickass_rutherford +``` + +Print a specific log file directly: + +```console +$ nextflow logfile .nextflow.log.4 +``` + +Show only warnings and errors from the most recent run (useful for piping to an LLM to save tokens): + +```console +$ nextflow logfile -level WARN +``` + +Show the last 10 entries of an ongoing run and continue streaming new lines: + +```console +$ nextflow logfile -n 10 -f +``` + (cli-module)= ### `module` diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/CmdLogFile.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/CmdLogFile.groovy new file mode 100644 index 0000000000..73d7bbdd4d --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/cli/CmdLogFile.groovy @@ -0,0 +1,448 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.cli + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.regex.Pattern + +import ch.qos.logback.classic.Level +import com.beust.jcommander.Parameter +import com.beust.jcommander.Parameters +import groovy.transform.CompileStatic +import groovy.transform.PackageScope +import groovy.util.logging.Slf4j +import nextflow.SysEnv +import nextflow.cli.LogFileFormatter.EntryHeader +import nextflow.exception.AbortOperationException +import nextflow.util.HistoryFile + +/** + * Implements the `logfile` command to print the contents of a Nextflow log file, + * resolved either by run name (via {@link HistoryFile}) or by an explicit file path. + * + * @author Phil Ewels + */ +@Slf4j +@CompileStatic +@Parameters(commandDescription = "Print the contents of a Nextflow log file") +class CmdLogFile extends CmdBase { + + static final public NAME = 'logfile' + + static final String LOG_FILE_NAME = '.nextflow.log' + + static final String DEFAULT_QUERY = 'last' + + static final List LEVEL_NAMES = ['TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'] + + /** Filename suffixes scanned when resolving a run name to a (possibly rotated) log file. */ + static final List LOG_FILE_SUFFIXES = ['', '.1', '.2', '.3', '.4', '.5', '.6', '.7', '.8', '.9'] + + /** + * Cap on lines scanned when locating a session UUID. The UUID is logged near the top of the + * file, but verbose plugin/classpath logging at -trace/-debug can push it down a few hundred + * lines, so the cap is generous to avoid spurious "cannot find log" resolutions. + */ + static final int SESSION_ID_SCAN_LIMIT = 1000 + + static final long FOLLOW_POLL_MS = 250 + + /** Matches an ANSI CSI escape sequence (color and style codes) found in input. */ + static final Pattern ANSI_CSI = ~/\[[\d;?]*[@-~]/ + + @Parameter(description = 'Run name, session id, or path to a .nextflow.log file') + List args + + @Parameter(names = ['-l','-level'], description = 'Minimum log level to print: TRACE, DEBUG, INFO, WARN, ERROR (no filtering by default)') + String level + + @Parameter(names = ['-f','-follow'], description = 'Output appended data as the file grows', arity = 0) + boolean follow + + @Parameter(names = ['-n','-lines'], description = 'Print only the last N log entries') + Integer lines + + @Parameter(names = ['-no-ansi'], description = 'Do not add color/style codes to the output (also disabled when NO_COLOR is set or stdout is not a terminal)', arity = 0) + boolean noAnsi + + @Parameter(names = ['-keep-ansi'], description = 'Preserve ANSI escape codes found inside log content (default: strip them)', arity = 0) + boolean keepAnsi + + @Parameter(names = ['-no-pager'], description = 'Do not pipe the output through a pager (default: pipe through $NXF_PAGER, then $PAGER, then `less -FR` when stdout is a terminal)', arity = 0) + boolean noPager + + @PackageScope HistoryFile history + + @PackageScope Path currentDir + + /** Test seam: when non-null, overrides TTY detection during color resolution. */ + @PackageScope Boolean colorOverride + + private PrintStream out = System.out + + private boolean stripAnsi + private LogFileFormatter formatter + + @Override + final String getName() { NAME } + + @Override + void run() { + if( args && args.size() > 1 ) + throw new AbortOperationException("Too many arguments — expected a single run name or file path") + if( lines != null && lines < 0 ) + throw new AbortOperationException("Option `-n` must be a non-negative integer") + + stripAnsi = !keepAnsi + final usePager = shouldUsePager() + formatter = new LogFileFormatter(resolveUseColor(usePager)) + + final threshold = parseLevel(level) + final path = resolveLogFile(args ? args[0] : null) + + if( follow ) { + // Snapshot the size once so the tail read and the follow seek share the same byte + // boundary — otherwise lines appended between them would be printed by neither. + final long startPos = Files.size(path) + if( lines != null ) + printLastN(path, threshold, lines, startPos) + followFile(path, threshold, startPos) + return + } + + if( usePager ) + runWithPager { doPrint(path, threshold) } + else + doPrint(path, threshold) + } + + private void doPrint(Path path, Level threshold) { + if( lines != null ) + printLastN(path, threshold, lines) + else + printAll(path, threshold) + } + + private boolean resolveUseColor(boolean usePager) { + if( noAnsi ) + return false + // Honor the same global gates as ColorUtil.isAnsiEnabled(): NO_COLOR disables, then + // NXF_ANSI_LOG forces on/off. These take precedence over pager/TTY detection so a user + // who disables color globally also gets uncolored `logfile` output. + if( SysEnv.get('NO_COLOR') != null ) + return false + final ansiLog = SysEnv.get('NXF_ANSI_LOG')?.trim() + if( ansiLog ) + return Boolean.parseBoolean(ansiLog) + if( colorOverride != null ) + return colorOverride + if( usePager ) + return true // pager handles the real terminal — keep colors + return System.console() != null + } + + @PackageScope + boolean shouldUsePager() { + if( noPager ) return false + if( follow ) return false // pagers buffer; tail-follow needs an unbuffered stream + return System.console() != null + } + + @PackageScope + static List resolvePagerCommand() { + final nxfPager = SysEnv.get('NXF_PAGER')?.trim() + if( nxfPager ) return parsePagerCommand(nxfPager) + final pager = SysEnv.get('PAGER')?.trim() + if( pager ) return parsePagerCommand(pager) + return ['less', '-FR'] + } + + @PackageScope + static List parsePagerCommand(String value) { + final parts = value.trim().tokenize() as List + if( parts.isEmpty() ) + return null + // Bare `less` (no args, with or without a path prefix) gets `-FR` appended so colors + // render (-R) and short output exits without entering the pager (-F). We omit `-X` + // (which git's default includes) because it disables the alternate screen buffer that + // most terminals rely on for mouse-wheel-to-arrow-key translation. + final cmd = parts[0] + final base = cmd.contains('/') ? cmd.substring(cmd.lastIndexOf('/') + 1) : cmd + if( parts.size() == 1 && base == 'less' ) + parts.add('-FR') + return parts + } + + private void runWithPager(Runnable printAction) { + final cmd = resolvePagerCommand() + if( !cmd ) { + printAction.run() + return + } + Process proc + try { + proc = new ProcessBuilder(cmd) + .redirectOutput(ProcessBuilder.Redirect.INHERIT) + .redirectError(ProcessBuilder.Redirect.INHERIT) + .start() + } + catch( IOException e ) { + log.debug "Could not start pager '${cmd.join(' ')}': ${e.message}" + printAction.run() + return + } + final originalOut = this.out + // Buffer the pipe to the pager so each autoflush'd println drains a coalesced + // buffer rather than emitting multiple small writes per line. + this.out = new PrintStream(new BufferedOutputStream(proc.outputStream), true, StandardCharsets.UTF_8) + try { + printAction.run() + } + finally { + try { this.out.close() } catch( Exception ignored ) {} + try { proc.waitFor() } catch( InterruptedException ignored) {} + this.out = originalOut + } + } + + private Path resolveLogFile(String arg) { + if( arg ) { + final candidate = Paths.get(arg) + if( Files.exists(candidate) ) { + if( !Files.isRegularFile(candidate) ) + throw new AbortOperationException("Not a regular file: $arg") + return candidate + } + } + + final query = arg?.trim() ?: DEFAULT_QUERY + final hist = history ?: HistoryFile.DEFAULT + if( !hist.exists() || hist.empty() ) + throw new AbortOperationException("It looks like no pipeline was executed (execution history is empty)") + + final records = hist.findByIdOrName(query) + if( !records ) + throw new AbortOperationException("Unknown run name or session id: $query") + + final record = records.last() + if( record.sessionId == null ) + throw new AbortOperationException("History record for run '${record.runName ?: query}' has no session id — pass the log file path explicitly") + final sessionId = record.sessionId.toString() + final dir = currentDir ?: Paths.get('.') + for( String suffix : LOG_FILE_SUFFIXES ) { + final candidate = dir.resolve("${LOG_FILE_NAME}${suffix}".toString()) + if( Files.exists(candidate) && fileContainsSessionId(candidate, sessionId) ) + return candidate + } + + throw new AbortOperationException( + "Cannot find a ${LOG_FILE_NAME} file in the current directory for run '${record.runName ?: query}' (session ${sessionId}). " + + "Pass the log file path explicitly.") + } + + private static boolean fileContainsSessionId(Path file, String sessionId) { + try { + return Files.newBufferedReader(file, StandardCharsets.UTF_8).withCloseable { reader -> + String line + int count = 0 + while( count++ < SESSION_ID_SCAN_LIMIT && (line = reader.readLine()) != null ) { + if( line.contains(sessionId) ) + return true + } + return false + } + } + catch( IOException e ) { + log.debug "Could not scan file ${file}: ${e.message}" + return false + } + } + + @PackageScope + static Level parseLevel(String value) { + if( !value ) + return null + final str = value.trim().toUpperCase() + // Level.toLevel accepts extras like ALL/OFF — gate on our supported set first + if( !(str in LEVEL_NAMES) ) + throw new AbortOperationException("Invalid log level: '${value}' — expected one of ${LEVEL_NAMES.join(', ')}") + return Level.toLevel(str) + } + + private static boolean passesFilter(Level entryLevel, Level threshold) { + if( threshold == null ) + return true + // treat unknown (continuation before any entry-start) as INFO so it isn't silently dropped at default thresholds + return (entryLevel ?: Level.INFO).isGreaterOrEqual(threshold) + } + + private String stripContentAnsi(String line) { + stripAnsi ? ANSI_CSI.matcher(line).replaceAll('') : line + } + + private void printAll(Path path, Level threshold) { + boolean keep = passesFilter(null, threshold) + Level currentLevel = null + Files.newBufferedReader(path, StandardCharsets.UTF_8).withCloseable { reader -> + String line + while( (line = reader.readLine()) != null ) { + line = stripContentAnsi(line) + final header = LogFileFormatter.parseEntryHeader(line) + if( header != null ) { + currentLevel = header.level + keep = passesFilter(currentLevel, threshold) + } + if( keep ) { + out.println(formatter.format(line, currentLevel, header)) + if( out.checkError() ) break // pager exited (broken pipe) — stop reading + } + } + } + } + + private void printLastN(Path path, Level threshold, int n, long endPos = Long.MAX_VALUE) { + if( n == 0 ) + return + final ArrayDeque buffer = new ArrayDeque<>(n) + StringBuilder pending = null + Level pendingLevel = null + boolean pendingKeep = passesFilter(null, threshold) + + final flush = { + if( pending != null && pendingKeep ) { + if( buffer.size() == n ) + buffer.removeFirst() + buffer.addLast(pending.toString()) + } + } + + boundedReader(path, endPos).withCloseable { reader -> + String line + while( (line = reader.readLine()) != null ) { + line = stripContentAnsi(line) + final header = LogFileFormatter.parseEntryHeader(line) + if( header != null ) { + flush.call() + pending = new StringBuilder().append(formatter.format(line, header.level, header)) + pendingKeep = passesFilter(header.level, threshold) + pendingLevel = header.level + } + else if( pending == null ) { + pending = new StringBuilder().append(formatter.format(line, null, null)) + pendingLevel = null + } + else { + pending.append('\n').append(formatter.format(line, pendingLevel, null)) + } + } + } + flush.call() + + for( String entry : buffer ) { + out.println(entry) + if( out.checkError() ) break // pager exited + } + } + + /** + * Open a reader over {@code path}, optionally bounded to the first {@code endPos} bytes so a + * tail read in follow mode stops exactly where {@link #followFile} resumes. + */ + private static BufferedReader boundedReader(Path path, long endPos) { + final InputStream raw = Files.newInputStream(path) + final InputStream src = endPos == Long.MAX_VALUE ? raw : new BoundedInputStream(raw, endPos) + return new BufferedReader(new InputStreamReader(src, StandardCharsets.UTF_8)) + } + + /** Caps the number of bytes read from a delegate stream. */ + @CompileStatic + private static class BoundedInputStream extends InputStream { + private final InputStream delegate + private long remaining + + BoundedInputStream(InputStream delegate, long limit) { + this.delegate = delegate + this.remaining = limit + } + + @Override + int read() throws IOException { + if( remaining <= 0 ) return -1 + final b = delegate.read() + if( b >= 0 ) remaining-- + return b + } + + @Override + int read(byte[] buf, int off, int len) throws IOException { + if( remaining <= 0 ) return -1 + final n = delegate.read(buf, off, (int) Math.min((long) len, remaining)) + if( n > 0 ) remaining -= n + return n + } + + @Override + void close() throws IOException { delegate.close() } + } + + // RandomAccessFile.readLine() reads bytes as ISO-8859-1. Nextflow log content is essentially + // ASCII (logger names, ISO timestamps, English messages), so this is equivalent to UTF-8 in + // practice. Non-ASCII characters in messages may be displayed incorrectly in follow mode. + private void followFile(Path path, Level threshold, long startPos) { + boolean keep = passesFilter(null, threshold) + Level currentLevel = null + RandomAccessFile raf = null + try { + raf = new RandomAccessFile(path.toFile(), 'r') + raf.seek(startPos) + while( !Thread.currentThread().isInterrupted() ) { + if( raf.length() < raf.getFilePointer() ) { + // file was truncated or rotated — reopen from the start + raf.close() + raf = new RandomAccessFile(path.toFile(), 'r') + keep = passesFilter(null, threshold) + currentLevel = null + } + String line + boolean read = false + while( (line = raf.readLine()) != null ) { + read = true + line = stripContentAnsi(line) + final header = LogFileFormatter.parseEntryHeader(line) + if( header != null ) { + currentLevel = header.level + keep = passesFilter(currentLevel, threshold) + } + if( keep ) + out.println(formatter.format(line, currentLevel, header)) + } + if( read ) + out.flush() + Thread.sleep(FOLLOW_POLL_MS) + } + } + catch( InterruptedException e ) { + Thread.currentThread().interrupt() + } + finally { + try { raf?.close() } catch( Exception ignored ) {} + } + } +} diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy index 387b58b67b..d0c4698869 100644 --- a/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy +++ b/modules/nextflow/src/main/groovy/nextflow/cli/Launcher.groovy @@ -99,6 +99,7 @@ class Launcher { new CmdLaunch(), new CmdList(), new CmdLog(), + new CmdLogFile(), new CmdPull(), new CmdRun(), new CmdKubeRun(), diff --git a/modules/nextflow/src/main/groovy/nextflow/cli/LogFileFormatter.groovy b/modules/nextflow/src/main/groovy/nextflow/cli/LogFileFormatter.groovy new file mode 100644 index 0000000000..5874e0cf76 --- /dev/null +++ b/modules/nextflow/src/main/groovy/nextflow/cli/LogFileFormatter.groovy @@ -0,0 +1,398 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.cli + +import java.util.regex.Matcher +import java.util.regex.Pattern + +import ch.qos.logback.classic.Level +import groovy.transform.CompileStatic +import groovy.transform.PackageScope + +/** + * Renders Nextflow log file lines for terminal display. + * + * Each line is prefixed with a 2-character level indicator and the message body is + * highlighted using rules ported from the nextflow-log TextMate grammar shipped with + * vscode-language-nextflow. + * + * @author Phil Ewels + */ +@CompileStatic +class LogFileFormatter { + + static final String ESC = '' + static final String ANSI_RESET = ESC + '[0m' + static final String ANSI_DIM = ESC + '[2m' + + // Level indicator: 2-char prefix. WARN/ERROR continuations use 1 colored cell + plain + // space; entry-start lines use 2 colored cells so the bg flows continuously into the + // timestamp emphasis with no visible gap. + static final String INDICATOR_TRACE = ESC + '[40m ' + ANSI_RESET + ' ' + static final String INDICATOR_INFO = INDICATOR_TRACE + static final String INDICATOR_WARN = ESC + '[43m ' + ANSI_RESET + ' ' + static final String INDICATOR_WARN_FILL = ESC + '[43m ' + ANSI_RESET + static final String INDICATOR_ERROR = ESC + '[41m ' + ANSI_RESET + ' ' + static final String INDICATOR_ERROR_FILL = ESC + '[41m ' + ANSI_RESET + static final String INDICATOR_PLAIN = ' ' + + static final String WARN_HIGHLIGHT = ESC + '[30;43m' // black fg on yellow bg + static final String ERROR_HIGHLIGHT = ESC + '[37;41m' // white fg on red bg + static final String WARN_BODY_FG = ESC + '[33m' // yellow fg + static final String ERROR_BODY_FG = ESC + '[31m' // red fg + + // Scopes referenced by stylizeHeader (separate from the body grammar rules) + private static final String SCOPE_TIMESTAMP = 'constant.other.timestamp.nextflow-log' + private static final String SCOPE_THREAD = 'entity.name.tag.thread.nextflow-log' + private static final String SCOPE_LOGGER = 'entity.name.class.logger.nextflow-log' + private static final String SCOPE_SEPARATOR = 'punctuation.separator.nextflow-log' + + /** Entry-start pattern. Captures: 1=timestamp, 2=thread, 3=level, 4=logger, 5=dash. */ + static final Pattern ENTRY_HEADER = ~/^([A-Z][a-z]{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) (\[[^\]]+\]) (TRACE|DEBUG|INFO|WARN|ERROR) +([\w$.]+) (-)/ + + /** Captured offsets of an entry-start match. Immutable snapshot — does not keep the Matcher. */ + @PackageScope + @CompileStatic + static class EntryHeader { + final Level level + final int timestampStart, timestampEnd + final int threadStart, threadEnd + final int levelStart, levelEnd + final int loggerStart, loggerEnd + final int dashStart, dashEnd + final int bodyStart + + EntryHeader(Matcher m) { + this.level = Level.toLevel(m.group(3)) + this.timestampStart = m.start(1); this.timestampEnd = m.end(1) + this.threadStart = m.start(2); this.threadEnd = m.end(2) + this.levelStart = m.start(3); this.levelEnd = m.end(3) + this.loggerStart = m.start(4); this.loggerEnd = m.end(4) + this.dashStart = m.start(5); this.dashEnd = m.end(5) + this.bodyStart = m.end() + } + } + + static EntryHeader parseEntryHeader(String line) { + final m = ENTRY_HEADER.matcher(line) + m.find() ? new EntryHeader(m) : null + } + + /** Mapping from TextMate-style scope name to ANSI escape sequence. */ + static final Map THEME = Collections.unmodifiableMap([ + (SCOPE_TIMESTAMP) : ESC + '[2m', + (SCOPE_THREAD) : ESC + '[36m', + (SCOPE_LOGGER) : ESC + '[32m', + (SCOPE_SEPARATOR) : ESC + '[2m', + 'keyword.control.section.nextflow-log' : ESC + '[1;33m', + 'entity.name.type.exception.nextflow-log' : ESC + '[1;31m', + 'string.unquoted.exception-message.nextflow-log': ESC + '[31m', + 'keyword.control.at.nextflow-log' : ESC + '[2m', + 'entity.name.function.stack-frame.nextflow-log' : ESC + '[36m', + 'punctuation.section.parens.begin.nextflow-log' : ESC + '[2m', + 'punctuation.section.parens.end.nextflow-log' : ESC + '[2m', + 'constant.other.source-location.nextflow-log' : ESC + '[2m', + 'constant.numeric.hash.work.nextflow-log' : ESC + '[33m', + 'string.unquoted.path.work.nextflow-log' : ESC + '[36m', + 'keyword.other.process-marker.nextflow-log' : ESC + '[35m', + 'entity.name.function.process.nextflow-log' : ESC + '[1;36m', + 'meta.process-instance.nextflow-log' : ESC + '[2m', + 'variable.parameter.port.nextflow-log' : ESC + '[34m', + 'support.type.channel-type.nextflow-log' : ESC + '[35m', + 'constant.language.state.nextflow-log' : ESC + '[1;32m', + 'variable.parameter.nextflow-log' : ESC + '[34m', + 'variable.other.channel.nextflow-log' : ESC + '[36m', + 'punctuation.separator.key-value.nextflow-log' : ESC + '[2m', + 'support.constant.script-id.nextflow-log' : ESC + '[33m', + 'support.module.plugin.nextflow-log' : ESC + '[35m', + 'punctuation.separator.version.nextflow-log' : ESC + '[2m', + 'support.constant.version.nextflow-log' : ESC + '[36m', + 'markup.underline.link.nextflow-log' : ESC + '[4;34m', + 'string.quoted.double.nextflow-log' : ESC + '[32m', + 'string.quoted.single.nextflow-log' : ESC + '[32m', + 'string.unquoted.path.nextflow-log' : ESC + '[36m', + 'string.unquoted.value.nextflow-log' : ESC + '[32m', + 'constant.numeric.nextflow-log' : ESC + '[33m', + 'keyword.other.unit.nextflow-log' : ESC + '[2;33m', + ] as Map) + + /** + * A body grammar rule. Capture group indexes are sorted ascending and their ANSI codes + * resolved from {@link #THEME} at construction so apply-time is allocation-free. + */ + @PackageScope + @CompileStatic + static class BodyRule { + final Pattern pattern + final int[] groupIndexes + final String[] groupCodes + + BodyRule(Pattern pattern, Map captures) { + this.pattern = pattern + final ids = new ArrayList(captures.keySet()) + Collections.sort(ids) + groupIndexes = new int[ids.size()] + groupCodes = new String[ids.size()] + for( int i = 0; i < ids.size(); i++ ) { + final id = ids.get(i) + groupIndexes[i] = id + groupCodes[i] = THEME.get(captures.get(id)) + } + } + } + + /** Body patterns, in priority order — `message-body` rules from the grammar. */ + static final List BODY_RULES = Collections.unmodifiableList([ + new BodyRule( + ~/^(Caused by|Command executed|Command exit status|Command output|Command error|Work dir|Tip):/, + [(1): 'keyword.control.section.nextflow-log']), + new BodyRule( + ~/^([\w.$]+(?:Exception|Error)): (.+)$/, + [(1): 'entity.name.type.exception.nextflow-log', + (2): 'string.unquoted.exception-message.nextflow-log']), + new BodyRule( + ~/^(\t+)(at) ([\w$.\/<>]+)(\()([^)]+)(\))/, + [(2): 'keyword.control.at.nextflow-log', + (3): 'entity.name.function.stack-frame.nextflow-log', + (4): 'punctuation.section.parens.begin.nextflow-log', + (5): 'constant.other.source-location.nextflow-log', + (6): 'punctuation.section.parens.end.nextflow-log']), + new BodyRule( + ~/\[[a-f0-9]{2}\/[a-f0-9]{6,}\]/, + [(0): 'constant.numeric.hash.work.nextflow-log']), + new BodyRule( + ~/\/work\/[a-f0-9]{2}\/[a-f0-9]{6,}[\w.\/-]*/, + [(0): 'string.unquoted.path.work.nextflow-log']), + new BodyRule( + ~/(process|workflow)( > )([A-Za-z0-9_:]+)((?:\s*\([^)]+\))?)/, + [(1): 'keyword.other.process-marker.nextflow-log', + (2): 'punctuation.separator.nextflow-log', + (3): 'entity.name.function.process.nextflow-log', + (4): 'meta.process-instance.nextflow-log']), + new BodyRule( + ~/^(\[process\]) ([A-Za-z0-9_:]+)/, + [(1): 'keyword.other.process-marker.nextflow-log', + (2): 'entity.name.function.process.nextflow-log']), + new BodyRule( + ~/(port \d+): (\((value|queue|cntrl)\)) (\S+)\s*; (channel): (.+)$/, + [(1): 'variable.parameter.port.nextflow-log', + (2): 'support.type.channel-type.nextflow-log', + (4): 'constant.language.state.nextflow-log', + (5): 'variable.parameter.nextflow-log', + (6): 'variable.other.channel.nextflow-log']), + new BodyRule( + ~/^( +)(status)(=)(\S+)/, + [(2): 'variable.parameter.nextflow-log', + (3): 'punctuation.separator.key-value.nextflow-log', + (4): 'constant.language.state.nextflow-log']), + new BodyRule( + ~/\bScript_[a-f0-9]+\b/, + [(0): 'support.constant.script-id.nextflow-log']), + new BodyRule( + ~/\b([a-zA-Z][\w-]*)(@)(\d[\d.]*)\b/, + [(1): 'support.module.plugin.nextflow-log', + (2): 'punctuation.separator.version.nextflow-log', + (3): 'support.constant.version.nextflow-log']), + new BodyRule( + ~/https?:\/\/[^\s)]+/, + [(0): 'markup.underline.link.nextflow-log']), + new BodyRule( + ~/"[^"\n]*"/, + [(0): 'string.quoted.double.nextflow-log']), + new BodyRule( + ~/'[^'\n]*'/, + [(0): 'string.quoted.single.nextflow-log']), + new BodyRule( + // Possessive quantifiers (++) keep this fully iterative: a contiguous slash-path of + // thousands of segments would otherwise recurse in the JDK engine and throw + // StackOverflowError. Semantics are unchanged for real paths (`/a/b/c`, `/a/b/c/`). + ~/(?) + + final boolean useColor + + LogFileFormatter(boolean useColor) { + this.useColor = useColor + } + + static String indicatorFor(Level level, boolean useColor, boolean isEntryStart) { + if( !useColor || level == null ) + return INDICATOR_PLAIN + if( level == Level.TRACE ) return INDICATOR_TRACE + if( level == Level.INFO ) return INDICATOR_INFO + if( level == Level.WARN ) return isEntryStart ? INDICATOR_WARN_FILL : INDICATOR_WARN + if( level == Level.ERROR ) return isEntryStart ? INDICATOR_ERROR_FILL : INDICATOR_ERROR + return INDICATOR_PLAIN // DEBUG and anything else + } + + /** + * Render a log line for output. {@code header} is non-null for entry-start lines and + * null for continuation lines (which inherit {@code currentLevel} from their entry). + */ + String format(String line, Level currentLevel, EntryHeader header) { + if( !useColor ) + return line + final indicator = indicatorFor(currentLevel, true, header != null) + if( currentLevel == Level.TRACE ) + return indicator + ANSI_DIM + line + ANSI_RESET + if( currentLevel == Level.DEBUG ) + return indicator + persistStyle(stylize(line, header, null), ANSI_DIM) + if( currentLevel == Level.WARN ) + return indicator + persistStyle(stylize(line, header, WARN_HIGHLIGHT), WARN_BODY_FG) + if( currentLevel == Level.ERROR ) + return indicator + persistStyle(stylize(line, header, ERROR_HIGHLIGHT), ERROR_BODY_FG) + return indicator + stylize(line, header, null) + } + + /** + * Wrap a styled span in a persistent attribute. Each internal {@code [0m} reset is + * followed by a fresh copy of {@code style} so the attribute remains active across the + * colored tokens produced by the grammar. + */ + private String persistStyle(String styled, String style) { + style + styled.replace(ANSI_RESET, ANSI_RESET + style) + ANSI_RESET + } + + private String stylize(String line, EntryHeader header, String headerEmphasis) { + if( header == null ) + return tokenizeBody(line) + return stylizeHeader(line, header, headerEmphasis) + tokenizeBody(line.substring(header.bodyStart)) + } + + private String stylizeHeader(String line, EntryHeader h, String headerEmphasis) { + final sb = new StringBuilder() + if( headerEmphasis != null ) { + // WARN/ERROR: emphasize the whole header region (timestamp + thread + level) as + // a single solid bg block so the indicator's bg flows continuously across. + sb.append(headerEmphasis).append(line, 0, h.levelEnd).append(ANSI_RESET) + } + else { + sb.append(line, 0, h.timestampStart) + appendColored(sb, line, h.timestampStart, h.timestampEnd, SCOPE_TIMESTAMP) + sb.append(line, h.timestampEnd, h.threadStart) + appendColored(sb, line, h.threadStart, h.threadEnd, SCOPE_THREAD) + sb.append(line, h.threadEnd, h.levelStart) + // level token plain — indicator block carries the signal + sb.append(line, h.levelStart, h.levelEnd) + } + sb.append(line, h.levelEnd, h.loggerStart) + appendColored(sb, line, h.loggerStart, h.loggerEnd, SCOPE_LOGGER) + sb.append(line, h.loggerEnd, h.dashStart) + appendColored(sb, line, h.dashStart, h.dashEnd, SCOPE_SEPARATOR) + return sb.toString() + } + + private static void appendColored(StringBuilder sb, CharSequence src, int start, int end, String scope) { + final code = THEME.get(scope) + if( code ) + sb.append(code).append(src, start, end).append(ANSI_RESET) + else + sb.append(src, start, end) + } + + private String tokenizeBody(String body) { + if( body.isEmpty() ) + return body + final n = body.length() + final sb = new StringBuilder() + // One Matcher per rule, allocated once for this line. `starts[i]` caches the start of + // each matcher's current match (-1 = exhausted). A matcher is only re-scanned when its + // cached match falls behind the cursor, so the inner loop stays O(rules·n) rather than + // re-allocating and re-scanning all rules at every position (previously ~O(rules·n²)). + final int rc = BODY_RULES.size() + final Matcher[] matchers = new Matcher[rc] + final int[] starts = new int[rc] + for( int i = 0; i < rc; i++ ) { + final m = BODY_RULES.get(i).pattern.matcher(body) + matchers[i] = m + starts[i] = m.find(0) ? m.start() : -1 + } + int pos = 0 + while( pos < n ) { + BodyRule bestRule = null + Matcher bestMatcher = null + int bestStart = Integer.MAX_VALUE + for( int i = 0; i < rc; i++ ) { + int s = starts[i] + if( s >= 0 && s < pos ) { + // cursor advanced past this match — find the next one at/after pos + final m = matchers[i] + s = m.find(pos) ? m.start() : -1 + starts[i] = s + } + if( s >= 0 && s < bestStart ) { + bestStart = s + bestRule = BODY_RULES.get(i) + bestMatcher = matchers[i] + } + } + if( bestRule == null ) { + sb.append(body, pos, n) + break + } + if( pos < bestStart ) + sb.append(body, pos, bestStart) + applyRule(sb, body, bestMatcher, bestRule) + // guard against zero-length matches that would loop forever + pos = bestMatcher.end() > bestStart ? bestMatcher.end() : bestStart + 1 + } + return sb.toString() + } + + private static void applyRule(StringBuilder sb, String body, Matcher m, BodyRule rule) { + final indexes = rule.groupIndexes + final codes = rule.groupCodes + // whole-match coloring + if( indexes.length == 1 && indexes[0] == 0 ) { + final code = codes[0] + if( code ) + sb.append(code).append(body, m.start(), m.end()).append(ANSI_RESET) + else + sb.append(body, m.start(), m.end()) + return + } + // per-group coloring; preserve unmarked text between captured groups + int cursor = m.start() + for( int i = 0; i < indexes.length; i++ ) { + final g = indexes[i] + final s = m.start(g) + final e = m.end(g) + if( s < 0 ) + continue + if( s > cursor ) + sb.append(body, cursor, s) + final code = codes[i] + if( code ) + sb.append(code).append(body, s, e).append(ANSI_RESET) + else + sb.append(body, s, e) + cursor = e + } + if( cursor < m.end() ) + sb.append(body, cursor, m.end()) + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/cli/CmdLogFileTest.groovy b/modules/nextflow/src/test/groovy/nextflow/cli/CmdLogFileTest.groovy new file mode 100644 index 0000000000..a11d9b3f41 --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/cli/CmdLogFileTest.groovy @@ -0,0 +1,532 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.cli + +import java.nio.file.Files +import java.nio.file.Path + +import ch.qos.logback.classic.Level +import nextflow.SysEnv +import nextflow.exception.AbortOperationException +import nextflow.util.HistoryFile +import org.junit.Rule +import spock.lang.Specification +import test.OutputCapture + +/** + * + * @author Phil Ewels + */ +class CmdLogFileTest extends Specification { + + @Rule + OutputCapture capture = new OutputCapture() + + def setup() { SysEnv.push([:]) } + def cleanup() { SysEnv.pop() } + + private static final String ESC = '' + + private static final String SAMPLE_LOG = """\ +May-26 14:30:01.000 [main] DEBUG nextflow.cli.Launcher - \$> nextflow run hello +May-26 14:30:01.100 [main] DEBUG nextflow.Session - Session UUID: aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee +May-26 14:30:01.200 [main] INFO nextflow.Session - Starting session +May-26 14:30:02.000 [Task submitter] INFO nextflow.Session - Task submitted +May-26 14:30:03.000 [main] WARN nextflow.processor.TaskProcessor - Something looked odd +May-26 14:30:04.000 [main] ERROR nextflow.Session - Pipeline failed +java.lang.RuntimeException: oh no +\tat foo.Bar.baz(Bar.java:42) +\tat foo.Bar.qux(Bar.java:21) +May-26 14:30:05.000 [main] INFO nextflow.Session - Done +""".stripIndent() + + def 'should print full file when given an explicit path'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = SAMPLE_LOG + + when: + new CmdLogFile(args: [logFile.toString()]).run() + + then: + final lines = capture.toString().readLines() + lines.any { it.contains('Session UUID') } + lines.any { it.contains('Starting session') } + lines.any { it.contains('Pipeline failed') } + lines.any { it == 'java.lang.RuntimeException: oh no' } + lines.any { it.contains('Bar.java:42') } + + cleanup: + tmp.toFile().deleteDir() + } + + def 'should filter out DEBUG entries when level threshold is INFO'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = SAMPLE_LOG + + when: + new CmdLogFile(args: [logFile.toString()], level: 'INFO').run() + + then: + final out = capture.toString() + !out.contains('DEBUG nextflow.cli.Launcher') + !out.contains('DEBUG nextflow.Session') + out.contains('INFO nextflow.Session - Starting session') + out.contains('WARN nextflow.processor.TaskProcessor') + out.contains('ERROR nextflow.Session - Pipeline failed') + + cleanup: + tmp.toFile().deleteDir() + } + + def 'should keep stack trace continuation lines with their parent entry'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = SAMPLE_LOG + + when: + new CmdLogFile(args: [logFile.toString()], level: 'ERROR').run() + + then: + final out = capture.toString() + out.contains('ERROR nextflow.Session - Pipeline failed') + out.contains('java.lang.RuntimeException: oh no') + out.contains('Bar.java:42') + out.contains('Bar.java:21') + !out.contains('Starting session') + !out.contains('Done') + + cleanup: + tmp.toFile().deleteDir() + } + + def 'should keep only the last N entries'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + def buf = new StringBuilder() + (1..10).each { i -> + buf.append(String.format("May-26 14:30:%02d.000 [main] INFO nextflow.Session - entry %d%n", i, i)) + } + logFile.text = buf.toString() + + when: + new CmdLogFile(args: [logFile.toString()], lines: 3).run() + + then: + final out = capture.toString().readLines().findAll { it.contains('entry') } + out.size() == 3 + out[0].contains('entry 8') + out[1].contains('entry 9') + out[2].contains('entry 10') + + cleanup: + tmp.toFile().deleteDir() + } + + def 'should keep N entries including multi-line stack traces'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = SAMPLE_LOG + + when: + // last 2 entries: ERROR with stack trace, then INFO Done + new CmdLogFile(args: [logFile.toString()], lines: 2).run() + + then: + final out = capture.toString() + out.contains('Pipeline failed') + out.contains('Bar.java:42') + out.contains('Done') + !out.contains('Starting session') + !out.contains('Task submitted') + + cleanup: + tmp.toFile().deleteDir() + } + + def 'should resolve an older run name to a rotated .nextflow.log file by session UUID'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final uuidNew = UUID.fromString('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + final uuidOld = UUID.fromString('11111111-2222-3333-4444-555555555555') + + // current run is in .nextflow.log; previous run is rotated to .nextflow.log.1 + tmp.resolve('.nextflow.log').text = SAMPLE_LOG // contains uuidNew + tmp.resolve('.nextflow.log.1').text = """\ +May-26 12:00:00.000 [main] DEBUG nextflow.Session - Session UUID: ${uuidOld} +May-26 12:00:01.000 [main] INFO nextflow.Session - older run +""".stripIndent() + + final history = new HistoryFile(tmp.resolve('history').toFile()) + history.write('old_run', uuidOld, 'rev1', 'run hello') + history.write('new_run', uuidNew, 'rev1', 'run hello') + + when: + new CmdLogFile(args: ['old_run'], history: history, currentDir: tmp).run() + + then: + final out = capture.toString() + out.contains('older run') + !out.contains('Starting session') + + cleanup: + tmp.toFile().deleteDir() + } + + def 'should resolve a current run name to the active .nextflow.log file'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final uuidNew = UUID.fromString('aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee') + final uuidOld = UUID.fromString('11111111-2222-3333-4444-555555555555') + + tmp.resolve('.nextflow.log').text = SAMPLE_LOG + tmp.resolve('.nextflow.log.1').text = """\ +May-26 12:00:00.000 [main] DEBUG nextflow.Session - Session UUID: ${uuidOld} +May-26 12:00:01.000 [main] INFO nextflow.Session - older run +""".stripIndent() + + final history = new HistoryFile(tmp.resolve('history').toFile()) + history.write('old_run', uuidOld, 'rev1', 'run hello') + history.write('new_run', uuidNew, 'rev1', 'run hello') + + when: + new CmdLogFile(args: ['new_run'], history: history, currentDir: tmp).run() + + then: + final out = capture.toString() + out.contains('Starting session') + !out.contains('older run') + + cleanup: + tmp.toFile().deleteDir() + } + + def 'should error when run name is not in history'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final history = new HistoryFile(tmp.resolve('history').toFile()) + history.write('only_run', UUID.randomUUID(), 'rev1', 'run') + + when: + new CmdLogFile(args: ['nope'], history: history, currentDir: tmp).run() + + then: + final ex = thrown(AbortOperationException) + ex.message.contains('Unknown run name') + + cleanup: + tmp.toFile().deleteDir() + } + + def 'should error when history is empty and no path given'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final history = new HistoryFile(tmp.resolve('history').toFile()) + + when: + new CmdLogFile(args: ['something'], history: history, currentDir: tmp).run() + + then: + final ex = thrown(AbortOperationException) + ex.message.contains('history is empty') || ex.message.contains('no pipeline') + + cleanup: + tmp.toFile().deleteDir() + } + + def 'should error when run name resolves but no matching log file is found'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final uuid = UUID.randomUUID() + // history has the run, but there is no .nextflow.log file containing the UUID + final history = new HistoryFile(tmp.resolve('history').toFile()) + history.write('orphan', uuid, 'rev1', 'run') + tmp.resolve('.nextflow.log').text = "May-26 12:00:00.000 [main] INFO nextflow.Session - unrelated\n" + + when: + new CmdLogFile(args: ['orphan'], history: history, currentDir: tmp).run() + + then: + final ex = thrown(AbortOperationException) + ex.message.contains('Cannot find') + ex.message.contains('orphan') + + cleanup: + tmp.toFile().deleteDir() + } + + def 'should reject too many arguments'() { + when: + new CmdLogFile(args: ['a', 'b']).run() + + then: + final ex = thrown(AbortOperationException) + ex.message.contains('Too many arguments') + } + + def 'should reject invalid level'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = SAMPLE_LOG + + when: + new CmdLogFile(args: [logFile.toString()], level: 'BANANA').run() + + then: + final ex = thrown(AbortOperationException) + ex.message.contains('Invalid log level') + + cleanup: + tmp.toFile().deleteDir() + } + + def 'parseLevel handles all supported levels case-insensitively'() { + expect: + CmdLogFile.parseLevel(input) == expected + + where: + input | expected + null | null + '' | null + 'trace' | Level.TRACE + 'Debug' | Level.DEBUG + 'INFO' | Level.INFO + 'warn' | Level.WARN + 'ERROR' | Level.ERROR + } + + // ---------------------------------------------------------------------- + // ANSI stripping + // ---------------------------------------------------------------------- + + def 'should strip ANSI codes from log content by default'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = "May-26 14:30:01.000 [main] INFO nextflow.Session - hello ${ESC}[31mred${ESC}[0m world\n" + + when: + new CmdLogFile(args: [logFile.toString()]).run() + + then: + final out = capture.toString() + out.contains('hello red world') + !out.contains(ESC) + + cleanup: + tmp.toFile().deleteDir() + } + + def 'should preserve ANSI codes in content when -keep-ansi is set'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = "May-26 14:30:01.000 [main] INFO nextflow.Session - hello ${ESC}[31mred${ESC}[0m world\n" + + when: + new CmdLogFile(args: [logFile.toString()], keepAnsi: true).run() + + then: + final out = capture.toString() + out.contains("hello ${ESC}[31mred${ESC}[0m world") + + cleanup: + tmp.toFile().deleteDir() + } + + // ---------------------------------------------------------------------- + // ANSI output integration (per-level indicator + grammar) + // ---------------------------------------------------------------------- + + def 'should emit ANSI indicators and grammar styling when color override is true'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = SAMPLE_LOG + + when: + new CmdLogFile(args: [logFile.toString()], colorOverride: true).run() + + then: + final out = capture.toString() + // Indicator blocks per level + out.contains(LogFileFormatter.INDICATOR_INFO) // black cell for INFO + out.contains(LogFileFormatter.INDICATOR_WARN_FILL) // 2 yellow cells on WARN entry-start + out.contains(LogFileFormatter.INDICATOR_ERROR_FILL) // 2 red cells on ERROR entry-start + out.contains(LogFileFormatter.INDICATOR_ERROR) // 1 red cell on ERROR continuations (stack trace) + // TRACE/DEBUG entries get full-line dim + out.contains("${ESC}[2m") + // grammar styling on INFO lines: dim timestamp, cyan thread, green logger + out.contains("${ESC}[2mMay-26 14:30:01.200${ESC}[0m") + out.contains("${ESC}[32mnextflow.Session${ESC}[0m") + // INFO level token stays plain + !out.contains("${ESC}[1mINFO${ESC}[0m") + // WARN/ERROR emit the full header region as one emphasis run + out.contains("${ESC}[30;43mMay-26 14:30:03.000 [main] WARN${ESC}[0m") + out.contains("${ESC}[37;41mMay-26 14:30:04.000 [main] ERROR${ESC}[0m") + + cleanup: + tmp.toFile().deleteDir() + } + + def '-no-ansi suppresses styling even when color override is true'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = SAMPLE_LOG + + when: + new CmdLogFile(args: [logFile.toString()], colorOverride: true, noAnsi: true).run() + + then: + final out = capture.toString() + !out.contains(ESC) + + cleanup: + tmp.toFile().deleteDir() + } + + def 'NO_COLOR env var suppresses styling even when color override is true'() { + given: + SysEnv.push([NO_COLOR: '1']) + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = SAMPLE_LOG + + when: + new CmdLogFile(args: [logFile.toString()], colorOverride: true).run() + + then: + final out = capture.toString() + !out.contains(ESC) + + cleanup: + tmp.toFile().deleteDir() + SysEnv.pop() + } + + def 'default behaviour in test env emits no ANSI (no TTY)'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = SAMPLE_LOG + + when: + new CmdLogFile(args: [logFile.toString()]).run() + + then: + final out = capture.toString() + !out.contains(ESC) + + cleanup: + tmp.toFile().deleteDir() + } + + // ---------------------------------------------------------------------- + // Pager integration + // ---------------------------------------------------------------------- + + def 'resolvePagerCommand defaults to less -FR when no env var is set'() { + expect: + CmdLogFile.resolvePagerCommand() == ['less', '-FR'] + } + + def 'resolvePagerCommand honors PAGER env var'() { + given: + SysEnv.push([PAGER: 'most -s']) + + expect: + CmdLogFile.resolvePagerCommand() == ['most', '-s'] + } + + def 'resolvePagerCommand prefers NXF_PAGER over PAGER'() { + given: + SysEnv.push([NXF_PAGER: 'bat --paging always', PAGER: 'less']) + + expect: + CmdLogFile.resolvePagerCommand() == ['bat', '--paging', 'always'] + } + + def 'parsePagerCommand appends -FR to a bare less invocation'() { + expect: + CmdLogFile.parsePagerCommand('less') == ['less', '-FR'] + CmdLogFile.parsePagerCommand('/usr/bin/less') == ['/usr/bin/less', '-FR'] + } + + def 'parsePagerCommand passes through less with explicit args'() { + expect: + CmdLogFile.parsePagerCommand('less -R') == ['less', '-R'] + CmdLogFile.parsePagerCommand('less -RFX') == ['less', '-RFX'] + } + + def 'parsePagerCommand handles arbitrary pagers without augmentation'() { + expect: + CmdLogFile.parsePagerCommand('most') == ['most'] + CmdLogFile.parsePagerCommand('cat') == ['cat'] + CmdLogFile.parsePagerCommand('bat --plain') == ['bat', '--plain'] + } + + def 'parsePagerCommand returns null for empty input'() { + expect: + CmdLogFile.parsePagerCommand('') == null + CmdLogFile.parsePagerCommand(' ') == null + } + + def 'shouldUsePager is false when -no-pager is set'() { + expect: + !new CmdLogFile(noPager: true).shouldUsePager() + } + + def 'shouldUsePager is false in follow mode'() { + expect: + !new CmdLogFile(follow: true).shouldUsePager() + } + + def 'shouldUsePager is false when stdout is not a terminal (no TTY in test env)'() { + expect: + !new CmdLogFile().shouldUsePager() + } + + def 'should preserve indicator + grammar on the last-N path'() { + given: + final tmp = Files.createTempDirectory('cmdlogfile-') + final logFile = tmp.resolve('.nextflow.log') + logFile.text = SAMPLE_LOG + + when: + new CmdLogFile(args: [logFile.toString()], lines: 2, colorOverride: true).run() + + then: + final out = capture.toString() + // last two entries: ERROR (with stack trace) + INFO Done + out.contains(LogFileFormatter.INDICATOR_ERROR_FILL) // ERROR entry-start + out.contains(LogFileFormatter.INDICATOR_ERROR) // ERROR continuation (stack trace) + out.contains(LogFileFormatter.INDICATOR_INFO + ESC + '[2mMay-26 14:30:05.000') + // exception line highlighted under the ERROR entry + out.contains("${ESC}[1;31mjava.lang.RuntimeException${ESC}[0m") + + cleanup: + tmp.toFile().deleteDir() + } +} diff --git a/modules/nextflow/src/test/groovy/nextflow/cli/LogFileFormatterTest.groovy b/modules/nextflow/src/test/groovy/nextflow/cli/LogFileFormatterTest.groovy new file mode 100644 index 0000000000..b4a89cfdbc --- /dev/null +++ b/modules/nextflow/src/test/groovy/nextflow/cli/LogFileFormatterTest.groovy @@ -0,0 +1,361 @@ +/* + * Copyright 2013-2026, Seqera Labs + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package nextflow.cli + +import ch.qos.logback.classic.Level +import spock.lang.Specification + +/** + * @author Phil Ewels + */ +class LogFileFormatterTest extends Specification { + + private static final String ESC = '' + + // ----- parseEntryHeader ----- + + def 'parseEntryHeader extracts level and capture positions from valid entry-start lines'() { + given: + final line = 'May-26 14:30:04.000 [main] ERROR nextflow.Session - Pipeline failed' + + when: + final h = LogFileFormatter.parseEntryHeader(line) + + then: + h != null + h.level == Level.ERROR + line.substring(h.timestampStart, h.timestampEnd) == 'May-26 14:30:04.000' + line.substring(h.threadStart, h.threadEnd) == '[main]' + line.substring(h.levelStart, h.levelEnd) == 'ERROR' + line.substring(h.loggerStart, h.loggerEnd) == 'nextflow.Session' + line.substring(h.dashStart, h.dashEnd) == '-' + h.bodyStart > 0 + } + + def 'parseEntryHeader returns null for continuation lines'() { + expect: + LogFileFormatter.parseEntryHeader('\tat foo.Bar.baz(Bar.java:42)') == null + LogFileFormatter.parseEntryHeader('java.lang.RuntimeException: oh no') == null + LogFileFormatter.parseEntryHeader('') == null + LogFileFormatter.parseEntryHeader('not a log line') == null + } + + def 'parseEntryHeader detects every supported level'() { + expect: + LogFileFormatter.parseEntryHeader("May-26 14:30:00.000 [main] $word foo.Bar - msg").level == expected + + where: + word | expected + 'TRACE' | Level.TRACE + 'DEBUG' | Level.DEBUG + 'INFO' | Level.INFO + 'WARN' | Level.WARN + 'ERROR' | Level.ERROR + } + + // ----- indicatorFor ----- + + def 'indicatorFor returns plain 2 spaces when useColor is false'() { + expect: + LogFileFormatter.indicatorFor(level, false, true) == ' ' + LogFileFormatter.indicatorFor(level, false, false) == ' ' + + where: + level << [Level.TRACE, Level.DEBUG, Level.INFO, Level.WARN, Level.ERROR, null] + } + + def 'indicatorFor returns per-level block, with WARN/ERROR using 2 cells on entry-start and 1 cell on continuations'() { + expect: + LogFileFormatter.indicatorFor(Level.TRACE, true, true) == LogFileFormatter.INDICATOR_TRACE + LogFileFormatter.indicatorFor(Level.TRACE, true, false) == LogFileFormatter.INDICATOR_TRACE + LogFileFormatter.indicatorFor(Level.DEBUG, true, true) == LogFileFormatter.INDICATOR_PLAIN + LogFileFormatter.indicatorFor(Level.DEBUG, true, false) == LogFileFormatter.INDICATOR_PLAIN + LogFileFormatter.indicatorFor(Level.INFO, true, true) == LogFileFormatter.INDICATOR_INFO + LogFileFormatter.indicatorFor(Level.INFO, true, false) == LogFileFormatter.INDICATOR_INFO + LogFileFormatter.indicatorFor(Level.WARN, true, true) == LogFileFormatter.INDICATOR_WARN_FILL + LogFileFormatter.indicatorFor(Level.WARN, true, false) == LogFileFormatter.INDICATOR_WARN + LogFileFormatter.indicatorFor(Level.ERROR, true, true) == LogFileFormatter.INDICATOR_ERROR_FILL + LogFileFormatter.indicatorFor(Level.ERROR, true, false) == LogFileFormatter.INDICATOR_ERROR + LogFileFormatter.indicatorFor(null, true, true) == LogFileFormatter.INDICATOR_PLAIN + } + + def 'level indicator escape codes match the requested colors'() { + expect: + LogFileFormatter.INDICATOR_TRACE.startsWith("${ESC}[40m") // black bg + LogFileFormatter.INDICATOR_INFO == LogFileFormatter.INDICATOR_TRACE // INFO uses the same black cell + LogFileFormatter.INDICATOR_WARN.startsWith("${ESC}[43m") // yellow bg (continuation: 1 cell + plain space) + LogFileFormatter.INDICATOR_WARN_FILL.startsWith("${ESC}[43m") // yellow bg (entry-start: 2 cells, no trailing space) + LogFileFormatter.INDICATOR_ERROR.startsWith("${ESC}[41m") // red bg + LogFileFormatter.INDICATOR_ERROR_FILL.startsWith("${ESC}[41m") + LogFileFormatter.INDICATOR_PLAIN == ' ' + // entry-start fills end with reset (no plain trailing space) so bg flows continuously into timestamp + LogFileFormatter.INDICATOR_WARN_FILL.endsWith("${ESC}[0m") + LogFileFormatter.INDICATOR_ERROR_FILL.endsWith("${ESC}[0m") + } + + // ----- format() — no-color passes through ----- + + def 'format returns line unchanged when useColor is false'() { + given: + final f = new LogFileFormatter(false) + final line = 'May-26 14:30:00.000 [main] INFO nextflow.Session - hello' + final h = LogFileFormatter.parseEntryHeader(line) + + expect: + f.format(line, Level.INFO, h) == line + } + + // ----- format() — TRACE/DEBUG wrapping ----- + + def 'TRACE entry: indicator + dim wrapper, no body grammar applied'() { + given: + final f = new LogFileFormatter(true) + final line = 'May-26 14:30:00.000 [main] TRACE nextflow.Session - msg' + final h = LogFileFormatter.parseEntryHeader(line) + + when: + final out = f.format(line, Level.TRACE, h) + + then: + out.startsWith(LogFileFormatter.INDICATOR_TRACE) + out.contains("${ESC}[2m") + out.endsWith("${ESC}[0m") + // body has no extra coloring (just the dim wrapper) + !out.contains("${ESC}[36m") // no cyan for thread + } + + def 'DEBUG entry: plain prefix + dim wrapper AND grammar coloring'() { + given: + final f = new LogFileFormatter(true) + final line = 'May-26 14:30:00.000 [main] DEBUG nextflow.Session - hello' + final h = LogFileFormatter.parseEntryHeader(line) + + when: + final out = f.format(line, Level.DEBUG, h) + + then: + out.startsWith(' ') // DEBUG uses plain 2-space prefix + out.startsWith(LogFileFormatter.INDICATOR_PLAIN + ESC + '[2m') + out.endsWith("${ESC}[0m") + // grammar styling still applied — thread, logger colored — and dim reactivated after each reset + out.contains("${ESC}[36m[main]${ESC}[0m${ESC}[2m") // cyan thread + reset + dim again + out.contains("${ESC}[32mnextflow.Session${ESC}[0m${ESC}[2m") // green logger + reset + dim again + } + + def 'TRACE entry: dim wrapper, no grammar coloring'() { + given: + final f = new LogFileFormatter(true) + final line = 'May-26 14:30:00.000 [main] TRACE nextflow.Session - msg' + final h = LogFileFormatter.parseEntryHeader(line) + + when: + final out = f.format(line, Level.TRACE, h) + + then: + out == LogFileFormatter.INDICATOR_TRACE + "${ESC}[2m" + line + "${ESC}[0m" + !out.contains("${ESC}[36m") // no cyan thread coloring + } + + def 'TRACE continuation lines inherit indicator and dim, no coloring'() { + given: + final f = new LogFileFormatter(true) + final line = '\tat foo.Bar.baz(Bar.java:42)' + + when: + final out = f.format(line, Level.TRACE, null) + + then: + out == LogFileFormatter.INDICATOR_TRACE + "${ESC}[2m" + line + "${ESC}[0m" + } + + def 'DEBUG continuation lines inherit indicator, dim AND grammar coloring'() { + given: + final f = new LogFileFormatter(true) + final line = '\tat foo.Bar.baz(Bar.java:42)' + + when: + final out = f.format(line, Level.DEBUG, null) + + then: + out.startsWith(' ') + out.contains("${ESC}[2m") + // grammar still applies: 'at' keyword dim, frame name cyan + out.contains("${ESC}[36mfoo.Bar.baz${ESC}[0m${ESC}[2m") + } + + // ----- format() — INFO/WARN/ERROR styling ----- + + def 'INFO entry-start: single black-bg indicator + grammar styling, no level-token highlight'() { + given: + final f = new LogFileFormatter(true) + final line = 'May-26 14:30:00.000 [main] INFO nextflow.Session - hello' + + when: + final out = f.format(line, Level.INFO, LogFileFormatter.parseEntryHeader(line)) + + then: + out.startsWith(LogFileFormatter.INDICATOR_INFO) + !out.contains("${ESC}[1mINFO${ESC}[0m") + out.contains(' INFO ') + // grammar applies normally to INFO: dim timestamp, cyan thread, green logger + out.contains("${ESC}[2mMay-26 14:30:00.000${ESC}[0m") + out.contains("${ESC}[36m[main]${ESC}[0m") + out.contains("${ESC}[32mnextflow.Session${ESC}[0m") + } + + def 'WARN entry-start: 2-cell indicator + continuous yellow bg through level token'() { + given: + final f = new LogFileFormatter(true) + final line = 'May-26 14:30:00.000 [main] WARN nextflow.Session - heads up' + + when: + final out = f.format(line, Level.WARN, LogFileFormatter.parseEntryHeader(line)) + + then: + out.startsWith(LogFileFormatter.INDICATOR_WARN_FILL) + out.contains("${ESC}[30;43mMay-26 14:30:00.000 [main] WARN${ESC}[0m") + !out.contains("${ESC}[36m[main]${ESC}[0m") + out.contains("${ESC}[0m${ESC}[33m") + out.contains("${ESC}[32mnextflow.Session${ESC}[0m") + } + + def 'ERROR entry-start: 2-cell indicator + continuous red bg through level token'() { + given: + final f = new LogFileFormatter(true) + final line = 'May-26 14:30:00.000 [main] ERROR nextflow.Session - oh no' + + when: + final out = f.format(line, Level.ERROR, LogFileFormatter.parseEntryHeader(line)) + + then: + out.startsWith(LogFileFormatter.INDICATOR_ERROR_FILL) + out.contains("${ESC}[37;41mMay-26 14:30:00.000 [main] ERROR${ESC}[0m") + !out.contains("${ESC}[36m[main]${ESC}[0m") + out.contains("${ESC}[0m${ESC}[31m") + out.contains("${ESC}[32mnextflow.Session${ESC}[0m") + } + + def 'WARN continuation line: single yellow cell + yellow body fg'() { + given: + final f = new LogFileFormatter(true) + final line = ' some extra warning context' + + when: + final out = f.format(line, Level.WARN, null) + + then: + out.startsWith(LogFileFormatter.INDICATOR_WARN + "${ESC}[33m") + !out.startsWith(LogFileFormatter.INDICATOR_WARN_FILL) // continuations use 1 cell, not 2 + out.endsWith("${ESC}[0m") + } + + def 'ERROR continuation line: single red cell + red body fg + grammar coloring still applies'() { + given: + final f = new LogFileFormatter(true) + final line = '\tat foo.Bar.baz(Bar.java:42)' + + when: + final out = f.format(line, Level.ERROR, null) + + then: + out.startsWith(LogFileFormatter.INDICATOR_ERROR + "${ESC}[31m") + !out.startsWith(LogFileFormatter.INDICATOR_ERROR_FILL) + out.contains("${ESC}[36mfoo.Bar.baz${ESC}[0m") + out.contains("${ESC}[0m${ESC}[31m") + } + + // ----- format() — grammar body rules ----- + + def 'format highlights work-hash in entry-start body'() { + given: + final f = new LogFileFormatter(true) + final line = 'May-26 14:30:00.000 [main] INFO nextflow.Session - [12/abcdef] Submitting task' + final h = LogFileFormatter.parseEntryHeader(line) + + when: + final out = f.format(line, Level.INFO, h) + + then: + out.contains("${ESC}[33m[12/abcdef]${ESC}[0m") + } + + def 'format highlights stack-trace continuation lines'() { + given: + final f = new LogFileFormatter(true) + final continuation = '\tat foo.Bar.baz(Bar.java:42)' + + when: + final out = f.format(continuation, Level.ERROR, null) + + then: + out.startsWith(LogFileFormatter.INDICATOR_ERROR) // 1-cell continuation indicator + !out.startsWith(LogFileFormatter.INDICATOR_ERROR_FILL) + out.contains("${ESC}[2mat${ESC}[0m") + out.contains("${ESC}[36mfoo.Bar.baz${ESC}[0m") + } + + def 'format highlights exception lines'() { + given: + final f = new LogFileFormatter(true) + final line = 'java.lang.RuntimeException: oh no' + + when: + final out = f.format(line, Level.ERROR, null) + + then: + out.contains("${ESC}[1;31mjava.lang.RuntimeException${ESC}[0m") + out.contains("${ESC}[31moh no${ESC}[0m") + } + + def 'format highlights URLs as underlined blue'() { + given: + final f = new LogFileFormatter(true) + final line = 'May-26 14:30:00.000 [main] INFO nextflow.Session - see https://nextflow.io/docs for help' + final h = LogFileFormatter.parseEntryHeader(line) + + when: + final out = f.format(line, Level.INFO, h) + + then: + out.contains("${ESC}[4;34mhttps://nextflow.io/docs${ESC}[0m") + } + + def 'format leaves pre-header lines (no level) unstyled apart from plain prefix'() { + given: + final f = new LogFileFormatter(true) + final line = 'some preamble before any log entry' + + when: + final out = f.format(line, null, null) + + then: + out == ' ' + line // plain 2-space prefix, no styling + } + + // ----- format() — useColor=false path adds no prefix ----- + + def 'useColor=false preserves bytes — no prefix, no ANSI'() { + given: + final f = new LogFileFormatter(false) + final line = 'May-26 14:30:00.000 [main] INFO nextflow.Session - hi' + + expect: + f.format(line, Level.INFO, LogFileFormatter.parseEntryHeader(line)) == line + f.format(line, Level.TRACE, LogFileFormatter.parseEntryHeader(line)) == line + f.format('continuation', Level.ERROR, null) == 'continuation' + } +}