CallerContradictionAnalyser.java

/*
 * Logback: the reliable, generic, fast and flexible logging framework.
 * Copyright (C) 1999-2026, QOS.ch. All rights reserved.
 *
 * This program and the accompanying materials are dual-licensed under
 * either the terms of the Eclipse Public License v2.0 as published by
 * the Eclipse Foundation
 *
 *   or (per the licensee's choosing)
 *
 * under the terms of the GNU Lesser General Public License version 2.1
 * as published by the Free Software Foundation.
 */
package ch.qos.logback.classic.model.processor;

import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;

import ch.qos.logback.classic.AsyncAppender;
import ch.qos.logback.classic.net.SMTPAppender;
import ch.qos.logback.classic.net.SocketAppender;
import ch.qos.logback.core.Context;
import ch.qos.logback.core.model.AppenderModel;
import ch.qos.logback.core.model.ImplicitModel;
import ch.qos.logback.core.model.Model;
import ch.qos.logback.core.model.processor.ModelHandlerBase;
import ch.qos.logback.core.model.processor.ModelHandlerException;
import ch.qos.logback.core.model.processor.ModelInterpretationContext;
import ch.qos.logback.core.model.processor.PhaseIndicator;
import ch.qos.logback.core.model.processor.ProcessingPhase;
import ch.qos.logback.core.util.OptionHelper;

/**
 * Dependency-analysis pass over every {@link AppenderModel}: records which
 * appenders suppress caller data ({@link AsyncAppender}, {@link SocketAppender}
 * or {@link SMTPAppender} with {@code includeCallerData=false} / default) and
 * which appenders need it (pattern contains a caller-data converter).
 *
 * <p>{@link SMTPAppender} is special: it both preprocesses caller data
 * ({@code includeCallerData}) and formats events via its own layout. Those two
 * contributions are recorded as separate map entries under
 * {@code name + }{@link #INCLUDE_CALLER_DATA_NAME_SUFFIX} and
 * {@code name + }{@link #LAYOUT_NAME_SUFFIX} so that contradictions within a
 * single SMTP appender (e.g. includeCallerData=false but layout uses
 * {@code %C}) can be detected.</p>
 *
 * <p>Analysis is skipped when the variable
 * {@value #SKIP_CALLER_CONTRADICTION_ANALYSIS_PROPERTY} is set to
 * {@code true} (context property, local property, system property or
 * environment variable).</p>
 *
 * <p>The contradiction check is performed by {@link CallerContradictionWarnAnalyser}
 * in its {@code postHandle()} on the enclosing {@code ConfigurationModel}, after
 * all appender models have been visited.</p>
 *
 * @since 1.6.2
 * @see CallerContradictionWarnAnalyser
 */
@PhaseIndicator(phase = ProcessingPhase.DEPENDENCY_ANALYSIS)
public class CallerContradictionAnalyser extends ModelHandlerBase {

    static final String APPENDER_TO_CALLER_INSTRUCTION_MAP_KEY = "APPENDER_TO_CALLER_INSTRUCTION_MAP_KEY";

    /**
     * When this property/variable is {@code true}, caller-contradiction analysis
     * is not performed.
     */
    public static final String SKIP_CALLER_CONTRADICTION_ANALYSIS_PROPERTY =
            "logback.skipCallerContradictionAnalysis";

    /**
     * Map-key suffix for an SMTPAppender {@code includeCallerData} contribution.
     */
    static final String INCLUDE_CALLER_DATA_NAME_SUFFIX = ".includeCallerData";

    /**
     * Map-key suffix for an SMTPAppender layout pattern contribution.
     */
    static final String LAYOUT_NAME_SUFFIX = ".layout";

    /**
     * Matches caller-data converter words in a logback pattern string.
     * Single-char forms (%C class, %M method, %L line, %F file, %l location) are
     * case-sensitive; multi-char aliases (class, method, line, file, caller) are
     * case-insensitive and unique enough to match without case sensitivity issues.
     * Negative lookahead prevents partial matches like %Msg being flagged.
     */
    static final Pattern CALLER_PATTERN = Pattern.compile(
            "%([CMLFl]|caller|class|method|line|file)(?![a-zA-Z])");

    public CallerContradictionAnalyser(Context context) {
        super(context);
    }

    @Override
    protected Class<AppenderModel> getSupportedModelClass() {
        return AppenderModel.class;
    }

    @Override
    public void handle(ModelInterpretationContext mic, Model model) throws ModelHandlerException {
        if (isSkipCallerContradictionAnalysis(mic)) {
            return;
        }

        AppenderModel appenderModel = (AppenderModel) model;

        Map<String, CallerInstructionLogic.Instruction> appenderNameToCallerInstructionMap
                = getAppenderNameToCallerInstructionMap(mic);

        String originalClassName = appenderModel.getClassName();
        String className = mic.getImport(originalClassName);
        String appenderName = mic.subst(appenderModel.getName());

        if (SMTPAppender.class.getName().equals(className)) {
            recordSmtpAppenderInstructions(mic, appenderModel, appenderName,
                    appenderNameToCallerInstructionMap);
            return;
        }

        if (isCallerDataPreprocessingAppender(className)) {
            if (isIncludeCallerDataTrue(mic, appenderModel)) {
                appenderNameToCallerInstructionMap.put(appenderName,
                        CallerInstructionLogic.Instruction.PREPROCESS_WANT);
            } else {
                appenderNameToCallerInstructionMap.put(appenderName,
                        CallerInstructionLogic.Instruction.DO_NOT_WANT);
            }
        }

        if (hasCallerDataConverters(appenderModel)) {
            appenderNameToCallerInstructionMap.put(appenderName, CallerInstructionLogic.Instruction.DIRECT_WANT);
        }
    }

    /**
     * Returns {@code true} when {@value #SKIP_CALLER_CONTRADICTION_ANALYSIS_PROPERTY}
     * resolves to {@code true}. Lookup order is local interpretation properties,
     * context properties, system properties, then environment variables.
     */
    static boolean isSkipCallerContradictionAnalysis(ModelInterpretationContext mic) {
        String value = OptionHelper.propertyLookup(SKIP_CALLER_CONTRADICTION_ANALYSIS_PROPERTY, mic,
                mic.getContext());
        return OptionHelper.toBoolean(value, false);
    }

    /**
     * Records SMTPAppender contributions as two distinct instructions so that
     * {@code includeCallerData} and the layout pattern can contradict each other.
     * <p>
     * The subject pattern is intentionally ignored; only the layout subtree is
     * considered for {@link CallerInstructionLogic.Instruction#DIRECT_WANT}.
     * </p>
     */
    private void recordSmtpAppenderInstructions(ModelInterpretationContext mic, AppenderModel appenderModel,
            String appenderName, Map<String, CallerInstructionLogic.Instruction> map) {
        String includeCallerDataKey = appenderName + INCLUDE_CALLER_DATA_NAME_SUFFIX;
        if (isIncludeCallerDataTrue(mic, appenderModel)) {
            map.put(includeCallerDataKey, CallerInstructionLogic.Instruction.PREPROCESS_WANT);
        } else {
            map.put(includeCallerDataKey, CallerInstructionLogic.Instruction.DO_NOT_WANT);
        }

        // Subject is not scanned ��� only layout patterns contribute DIRECT_WANT.
        Model layoutModel = findLayoutSubModel(appenderModel);
        if (layoutModel != null && hasCallerDataConvertersIn(layoutModel)) {
            map.put(appenderName + LAYOUT_NAME_SUFFIX, CallerInstructionLogic.Instruction.DIRECT_WANT);
        }
    }

    private Model findLayoutSubModel(AppenderModel appenderModel) {
        for (Model child : appenderModel.getSubModels()) {
            if ("layout".equalsIgnoreCase(child.getTag())) {
                return child;
            }
        }
        return null;
    }

    /**
     * Appenders that optionally extract caller data before deferred processing
     * or serialization, controlled by the {@code includeCallerData} property
     * (default {@code false}). {@link SMTPAppender} is handled separately.
     */
    private boolean isCallerDataPreprocessingAppender(String className) {
        return AsyncAppender.class.getName().equals(className)
                || SocketAppender.class.getName().equals(className);
    }

    /**
     * Note that includeCallerData is false by default on AsyncAppender,
     * SocketAppender and SMTPAppender, so if the tag is absent we treat it as
     * false.
     *
     * @param mic
     * @param appenderModel
     * @return
     */
    private boolean isIncludeCallerDataTrue(ModelInterpretationContext mic,
            AppenderModel appenderModel) {
        for (Model child : appenderModel.getSubModels()) {
            if (child instanceof ImplicitModel
                    && "includeCallerData".equalsIgnoreCase(child.getTag())) {
                String value = mic.subst(((ImplicitModel) child).getBodyText());
                return "true".equalsIgnoreCase(value);
            }
        }
        return false; // absent ��� default false
    }

    private boolean hasCallerDataConverters(AppenderModel appenderModel) {
        return hasCallerDataConvertersIn(appenderModel);
    }

    private boolean hasCallerDataConvertersIn(Model model) {
        return collectPatternBodyTexts(model).stream()
                .anyMatch(p -> CALLER_PATTERN.matcher(p).find());
    }

    private Set<String> collectPatternBodyTexts(Model model) {
        Set<String> patterns = new LinkedHashSet<>();
        collectPatternBodyTextsRecursive(model, patterns);
        return patterns;
    }

    private void collectPatternBodyTextsRecursive(Model model, Set<String> out) {
        if (model instanceof ImplicitModel && "pattern".equalsIgnoreCase(model.getTag())) {
            String body = model.getBodyText();
            if (body != null) {
                out.add(body);
            }
        }
        for (Model child : model.getSubModels()) {
            collectPatternBodyTextsRecursive(child, out);
        }
    }

    @SuppressWarnings("unchecked")
    static Map<String, CallerInstructionLogic.Instruction> getAppenderNameToCallerInstructionMap(ModelInterpretationContext mic) {
        Map<String, CallerInstructionLogic.Instruction> map =
                (Map<String, CallerInstructionLogic.Instruction>) mic.getObjectMap().get(APPENDER_TO_CALLER_INSTRUCTION_MAP_KEY);
        if (map == null) {
            map = new LinkedHashMap<>();
            mic.getObjectMap().put(APPENDER_TO_CALLER_INSTRUCTION_MAP_KEY, map);
        }
        return map;
    }
}