From 784b2a830cd2cd98ef76a286e35727019f293836 Mon Sep 17 00:00:00 2001 From: Tim Likarish Ellis Date: Thu, 6 Nov 2025 10:40:54 -0500 Subject: [PATCH 1/8] [VSCode] Add some basic tests for the extension --- editors/code/package.json | 1 + editors/code/src/test/suite/extension.test.ts | 21 ++++++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/editors/code/package.json b/editors/code/package.json index 482efe88..497f6aa5 100644 --- a/editors/code/package.json +++ b/editors/code/package.json @@ -3,6 +3,7 @@ "displayName": "log2src", "description": "A debugger driven by logs", "version": "0.0.1", + "publisher": "ttiimm", "author": { "name": "Tim Likarish Ellis" }, diff --git a/editors/code/src/test/suite/extension.test.ts b/editors/code/src/test/suite/extension.test.ts index b13f0a93..f789d580 100644 --- a/editors/code/src/test/suite/extension.test.ts +++ b/editors/code/src/test/suite/extension.test.ts @@ -5,8 +5,23 @@ import * as vscode from 'vscode'; suite('Extension Test Suite', () => { vscode.window.showInformationMessage('Start all tests.'); - test('Sample test', () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)); - assert.strictEqual(-1, [1, 2, 3].indexOf(0)); + test('Extension should be present', () => { + assert.ok(vscode.extensions.getExtension('ttiimm.log2src-ext')); + }); + + test('Extension should activate', async () => { + const ext = vscode.extensions.getExtension('ttiimm.log2src-ext'); + assert.ok(ext); + await ext!.activate(); + assert.strictEqual(ext!.isActive, true); + }); + + test('Should register log2src debug type', async () => { + // Ensure extension is activated + const ext = vscode.extensions.getExtension('ttiimm.log2src-ext'); + await ext?.activate(); + + // Verify the extension activated successfully + assert.ok(ext?.isActive, 'Extension should be active'); }); }); From 62a0bc6412415a1af9809940bedf910ccc5279ec Mon Sep 17 00:00:00 2001 From: Tim Likarish Ellis Date: Thu, 6 Nov 2025 18:56:59 -0500 Subject: [PATCH 2/8] [GitHub] Try and get extension tests running on branch --- .github/workflows/main.yml | 58 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 2814b2f4..e17332fb 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -12,7 +12,7 @@ env: CARGO_TERM_COLOR: always jobs: - build-and-test: + lib-build-and-test: name: Build & Test runs-on: ${{ matrix.os }} timeout-minutes: 30 @@ -56,10 +56,64 @@ jobs: run: cargo build --verbose - name: Run tests run: cargo test --verbose + + - name: Build release binary for extension tests (macOS ARM64 only) + if: matrix.target == 'aarch64-apple-darwin' + run: cargo build --release --target ${{ matrix.target }} + + - name: Upload release binary for extension tests (macOS ARM64 only) + if: matrix.target == 'aarch64-apple-darwin' + uses: actions/upload-artifact@v4 + with: + name: log2src-test-binary + path: target/${{ matrix.target }}/release/${{ matrix.artifact_name }} + if-no-files-found: error + + vscode-extension-test: + name: Test VS Code Extension + needs: [lib-build-and-test] + runs-on: macos-latest + timeout-minutes: 15 + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download log2src binary + uses: actions/download-artifact@v4 + with: + name: log2src-test-binary + path: downloaded-binary + + - name: Setup binary for VS Code extension + run: | + mkdir -p editors/code/bin/darwin-arm64 + cp downloaded-binary/log2src editors/code/bin/darwin-arm64/ + chmod +x editors/code/bin/darwin-arm64/log2src + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.8.0 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'pnpm' + cache-dependency-path: 'editors/code/pnpm-lock.yaml' + + - name: Install Dependencies + working-directory: editors/code + run: pnpm install + + - name: Run tests + working-directory: editors/code + run: pnpm run test build-release: name: Build Release - needs: [build-and-test] + needs: [lib-build-and-test] if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') runs-on: ${{ matrix.os }} timeout-minutes: 30 From 95df1eac138e33bf47b0199b94ab47e94ac91337 Mon Sep 17 00:00:00 2001 From: Tim Likarish Ellis Date: Fri, 7 Nov 2025 11:15:03 -0500 Subject: [PATCH 3/8] [DebugAdapter] Add some unit tests --- .../code/src/test/suite/debugAdapter.test.ts | 265 ++++++++++++++++++ 1 file changed, 265 insertions(+) create mode 100644 editors/code/src/test/suite/debugAdapter.test.ts diff --git a/editors/code/src/test/suite/debugAdapter.test.ts b/editors/code/src/test/suite/debugAdapter.test.ts new file mode 100644 index 00000000..9698b0e4 --- /dev/null +++ b/editors/code/src/test/suite/debugAdapter.test.ts @@ -0,0 +1,265 @@ +import * as assert from 'assert'; +import { DebugProtocol } from '@vscode/debugprotocol'; + +import { DebugSession, BinaryNotFoundError } from '../../debugAdapter'; + + +suite('DebugAdapter Test Suite', () => { + let debugSession: DebugSession | undefined; + let originalPlatform: string; + let originalArch: string; + + setup(() => { + originalPlatform = process.platform; + originalArch = process.arch; + }); + + teardown(() => { + Object.defineProperty(process, 'platform', { + value: originalPlatform, + configurable: true + }); + Object.defineProperty(process, 'arch', { + value: originalArch, + configurable: true + }); + + if (debugSession) { + try { + (debugSession as any).dispose?.(); + } catch (e) { + // Ignore + } + debugSession = undefined; + } + }); + + suite('Constructor Tests', () => { + test('Should create debug session with correct binary path for darwin-arm64', () => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true + }); + Object.defineProperty(process, 'arch', { + value: 'arm64', + configurable: true + }); + + debugSession = new DebugSession(); + + assert.ok(debugSession, 'Debug session should be created'); + }); + + test('Should create debug session with correct binary path for linux-x64', () => { + Object.defineProperty(process, 'platform', { + value: 'linux', + configurable: true + }); + Object.defineProperty(process, 'arch', { + value: 'x64', + configurable: true + }); + + debugSession = new DebugSession(); + + assert.ok(debugSession, 'Debug session should be created'); + }); + + test('Should create debug session with correct binary path for win32-x64', () => { + Object.defineProperty(process, 'platform', { + value: 'win32', + configurable: true + }); + Object.defineProperty(process, 'arch', { + value: 'x64', + configurable: true + }); + + debugSession = new DebugSession(); + + assert.ok(debugSession, 'Debug session should be created'); + }); + + test('Should throw BinaryNotFoundError for unsupported platform', () => { + Object.defineProperty(process, 'platform', { + value: 'unsupported', + configurable: true + }); + Object.defineProperty(process, 'arch', { + value: 'unsupported', + configurable: true + }); + + assert.throws(() => { + new DebugSession(); + }, BinaryNotFoundError, 'Should throw BinaryNotFoundError for unsupported platform'); + }); + }); + + suite('Initialize Request Tests', () => { + setup(() => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true + }); + Object.defineProperty(process, 'arch', { + value: 'arm64', + configurable: true + }); + debugSession = new DebugSession(); + }); + + test('Should handle initialize request correctly', () => { + const args: DebugProtocol.InitializeRequestArguments = { + clientID: 'test-client', + clientName: 'Test Client', + adapterID: 'log2src', + pathFormat: 'path' + }; + + const response: DebugProtocol.InitializeResponse = { + request_seq: 1, + success: true, + command: 'initialize', + seq: 1, + type: 'response', + body: {} + }; + + let capturedResponse: DebugProtocol.InitializeResponse | undefined; + const originalSendResponse = (debugSession as any).sendResponse.bind(debugSession); + (debugSession as any).sendResponse = (resp: any) => { + capturedResponse = resp; + }; + + let eventSent = false; + const originalSendEvent = (debugSession as any).sendEvent.bind(debugSession); + (debugSession as any).sendEvent = () => { + eventSent = true; + }; + + try { + (debugSession as any).initializeRequest(response, args); + + assert.ok(capturedResponse, 'Response should be sent'); + assert.ok(eventSent, 'Event should be sent'); + + assert.ok(capturedResponse!.body, 'Response should have body'); + assert.strictEqual(capturedResponse!.body.supportsStepBack, true); + assert.strictEqual(capturedResponse!.body.supportTerminateDebuggee, true); + } finally { + (debugSession as any).sendResponse = originalSendResponse; + (debugSession as any).sendEvent = originalSendEvent; + } + }); + }); + + suite('Breakpoint Tests', () => { + setup(() => { + Object.defineProperty(process, 'platform', { + value: 'darwin', + configurable: true + }); + Object.defineProperty(process, 'arch', { + value: 'arm64', + configurable: true + }); + debugSession = new DebugSession(); + }); + + test('Should set breakpoints correctly', () => { + const sourcePath = '/test/source/file.log'; + const args: DebugProtocol.SetBreakpointsArguments = { + source: { path: sourcePath }, + breakpoints: [ + { line: 10 }, + { line: 20 }, + { line: 30 } + ] + }; + + const response: DebugProtocol.SetBreakpointsResponse = { + request_seq: 1, + success: true, + command: 'setBreakpoints', + seq: 1, + type: 'response', + body: { breakpoints: [] } + }; + + let capturedResponse: DebugProtocol.SetBreakpointsResponse | undefined; + const originalSendResponse = (debugSession as any).sendResponse.bind(debugSession); + (debugSession as any).sendResponse = (resp: any) => { + capturedResponse = resp; + }; + + let eventSent = false; + const originalSendEvent = (debugSession as any).sendEvent.bind(debugSession); + (debugSession as any).sendEvent = () => { + eventSent = true; + }; + + try { + (debugSession as any).setBreakPointsRequest(response, args); + + assert.ok(capturedResponse, 'Response should be sent'); + + assert.ok(capturedResponse!.body, 'Response should have body'); + assert.ok(capturedResponse!.body.breakpoints, 'Response should have breakpoints'); + assert.strictEqual(capturedResponse!.body.breakpoints.length, 3, 'Should have 3 breakpoints'); + + capturedResponse!.body.breakpoints.forEach((bp, index) => { + assert.strictEqual(bp.line, args.breakpoints![index].line, `Breakpoint ${index} should have correct line`); + }); + + assert.ok(eventSent, 'Should send stopped event'); + } finally { + (debugSession as any).sendResponse = originalSendResponse; + (debugSession as any).sendEvent = originalSendEvent; + } + }); + + test('Should handle empty breakpoints array', () => { + const sourcePath = '/test/source/file.log'; + const args: DebugProtocol.SetBreakpointsArguments = { + source: { path: sourcePath }, + breakpoints: [] + }; + + const response: DebugProtocol.SetBreakpointsResponse = { + request_seq: 1, + success: true, + command: 'setBreakpoints', + seq: 1, + type: 'response', + body: { breakpoints: [] } + }; + + let capturedResponse: DebugProtocol.SetBreakpointsResponse | undefined; + let eventSent = false; + + const originalSendResponse = (debugSession as any).sendResponse.bind(debugSession); + (debugSession as any).sendResponse = (resp: any) => { + capturedResponse = resp; + }; + + const originalSendEvent = (debugSession as any).sendEvent.bind(debugSession); + (debugSession as any).sendEvent = () => { + eventSent = true; + }; + + try { + (debugSession as any).setBreakPointsRequest(response, args); + + assert.ok(capturedResponse, 'Response should be sent'); + assert.ok(capturedResponse!.body, 'Response should have body'); + assert.ok(capturedResponse!.body.breakpoints, 'Response should have breakpoints array'); + assert.strictEqual(capturedResponse!.body.breakpoints.length, 0, 'Should have no breakpoints'); + assert.strictEqual(eventSent, false, 'Should not send stopped event for empty breakpoints'); + } finally { + (debugSession as any).sendResponse = originalSendResponse; + (debugSession as any).sendEvent = originalSendEvent; + } + }); + }); +}); \ No newline at end of file From a834f52560ead06023cfb27544d30585509ef69b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:07:23 +0000 Subject: [PATCH 4/8] Annotate extracted log statements with their log level Keep the logging call name captured before the format string in each language's query and normalize it (e.g. log::warn, logger.warning, spdlog::info, SPDLOG_ERROR) to a LogLevel on SourceRef. The level is serialized as an optional "level" field in the srcRef output. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014JycCPeVRxuveoeMtHnJmt --- editors/code/src/debugAdapter.ts | 1 + src/lib.rs | 124 +++++++++++++++++- .../log2src__tests__backtrace_re-3.snap | 2 + .../log2src__tests__basic_python.snap | 4 + .../log2src__tests__extract_logging.snap | 5 + ...og2src__tests__link_to_quality_source.snap | 1 + src/source_query.rs | 10 ++ src/source_ref.rs | 70 ++++++++++ tests/snapshots/test_java__basic.snap | 8 +- .../test_java__basic_invalid_utf.snap | 8 +- tests/snapshots/test_java__basic_range.snap | 4 +- tests/snapshots/test_java__basic_slf4j.snap | 4 +- .../snapshots/test_java__basic_with_log.snap | 8 +- .../test_java__basic_with_log_format.snap | 8 +- .../test_java__basic_with_upper.snap | 8 +- tests/snapshots/test_rust__basic.snap | 12 +- tests/snapshots/test_rust__stack.snap | 2 +- 17 files changed, 247 insertions(+), 32 deletions(-) diff --git a/editors/code/src/debugAdapter.ts b/editors/code/src/debugAdapter.ts index 4434ebd2..77db0503 100644 --- a/editors/code/src/debugAdapter.ts +++ b/editors/code/src/debugAdapter.ts @@ -41,6 +41,7 @@ interface SourceRef { lineNumber: number, column: number, name: string, + level?: string, } interface ILaunchRequestArguments extends DebugProtocol.LaunchRequestArguments { diff --git a/src/lib.rs b/src/lib.rs index 57d656ff..907ae4b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -31,7 +31,7 @@ pub use progress::ProgressUpdate; pub use progress::WorkInfo; use source_query::QueryResult; pub use source_query::SourceQuery; -pub use source_ref::SourceRef; +pub use source_ref::{LogLevel, SourceRef}; #[derive(Error, Debug, Diagnostic, Clone)] pub enum LogError { @@ -1157,6 +1157,128 @@ processing \started -- {args[0]}""") ); } + fn extract_levels(path: &str, source: &str) -> Vec<(String, Option)> { + let code = CodeSource::from_string(&Path::new(path), source); + extract_logging(&[code], &ProgressTracker::new()) + .pop() + .unwrap() + .log_statements + .into_iter() + .map(|src_ref| (src_ref.text, src_ref.level)) + .collect() + } + + #[test] + fn test_levels_rust() { + let levels = extract_levels( + "in-mem.rs", + r#" +fn main() { + trace!("trace message"); + debug!("debug message"); + info!("info message"); + log::warn!("warn message"); + tracing::error!("error message"); + println!("plain message"); +} +"#, + ); + assert_eq!( + levels, + vec![ + ("\"trace message\"".to_string(), Some(LogLevel::Trace)), + ("\"debug message\"".to_string(), Some(LogLevel::Debug)), + ("\"info message\"".to_string(), Some(LogLevel::Info)), + ("\"warn message\"".to_string(), Some(LogLevel::Warn)), + ("\"error message\"".to_string(), Some(LogLevel::Error)), + ("\"plain message\"".to_string(), None), + ] + ); + } + + #[test] + fn test_levels_java() { + let levels = extract_levels( + "in-mem.java", + r#" +class Main { + void run() { + LOG.trace("trace message"); + log.debug("debug message"); + logger.info("info message"); + LOGGER.warn("warn message"); + LOG.error("error message"); + } +} +"#, + ); + assert_eq!( + levels, + vec![ + ("\"trace message\"".to_string(), Some(LogLevel::Trace)), + ("\"debug message\"".to_string(), Some(LogLevel::Debug)), + ("\"info message\"".to_string(), Some(LogLevel::Info)), + ("\"warn message\"".to_string(), Some(LogLevel::Warn)), + ("\"error message\"".to_string(), Some(LogLevel::Error)), + ] + ); + } + + #[test] + fn test_levels_cpp() { + let levels = extract_levels( + "in-mem.cc", + r#" +int main(int argc, char* argv[]) { + spdlog::info("info message"); + logger->warn("warn message"); + SPDLOG_ERROR("error message"); + spdlog::critical("critical message"); + printf("plain message"); +} +"#, + ); + assert_eq!( + levels, + vec![ + ("\"info message\"".to_string(), Some(LogLevel::Info)), + ("\"warn message\"".to_string(), Some(LogLevel::Warn)), + ("\"error message\"".to_string(), Some(LogLevel::Error)), + ("\"critical message\"".to_string(), Some(LogLevel::Fatal)), + ("\"plain message\"".to_string(), None), + ] + ); + } + + #[test] + fn test_levels_python() { + let levels = extract_levels( + "in-mem.py", + r#" +def main(): + logger.debug("debug message") + logging.info("info message") + logger.warning("warn message") + self.log.error("error message") + logger.exception("exception message") + logger.critical("critical message") + print("plain message") +"#, + ); + assert_eq!( + levels, + vec![ + ("\"debug message\"".to_string(), Some(LogLevel::Debug)), + ("\"info message\"".to_string(), Some(LogLevel::Info)), + ("\"warn message\"".to_string(), Some(LogLevel::Warn)), + ("\"error message\"".to_string(), Some(LogLevel::Error)), + ("\"exception message\"".to_string(), Some(LogLevel::Error)), + ("\"critical message\"".to_string(), Some(LogLevel::Fatal)), + ("\"plain message\"".to_string(), None), + ] + ); + } + const TRACE: &str = r#"JvmPauseMonitor-n0: Started java.lang.IllegalStateException: simulated failure for demo at org.example.Main.simulateError(Main.java:50) diff --git a/src/snapshots/log2src__tests__backtrace_re-3.snap b/src/snapshots/log2src__tests__backtrace_re-3.snap index 26aff0d8..c6f6bc02 100644 --- a/src/snapshots/log2src__tests__backtrace_re-3.snap +++ b/src/snapshots/log2src__tests__backtrace_re-3.snap @@ -9,6 +9,7 @@ expression: src_refs column: 13 name: run text: "\"{}: Started\"" + level: Info quality: 8 pattern: "(?s)^(.+): Started$" args: @@ -22,6 +23,7 @@ expression: src_refs column: 15 name: run text: "\"{}: Stopped\"" + level: Info quality: 8 pattern: "(?s)^(.+): Stopped$" args: diff --git a/src/snapshots/log2src__tests__basic_python.snap b/src/snapshots/log2src__tests__basic_python.snap index ad6b625d..2e9ce26b 100644 --- a/src/snapshots/log2src__tests__basic_python.snap +++ b/src/snapshots/log2src__tests__basic_python.snap @@ -9,6 +9,7 @@ expression: src_refs column: 16 name: main text: "\"foo %s \\N{greek small letter pi}\"" + level: Info quality: 5 pattern: "(?s)^foo (.+) \\w$" args: @@ -22,6 +23,7 @@ expression: src_refs column: 17 name: main text: "f'Hello, {args[1]}!'" + level: Info quality: 7 pattern: "(?s)^Hello, (.+)!$" args: @@ -34,6 +36,7 @@ expression: src_refs column: 19 name: main text: "f\"warning message:\\nlow disk space\"" + level: Warn quality: 29 pattern: "(?s)^warning message:\\nlow disk space$" args: [] @@ -45,6 +48,7 @@ expression: src_refs column: 16 name: main text: "rf\"\"\"info message:\nprocessing \\started -- {args[0]}\"\"\"" + level: Info quality: 35 pattern: "(?s)^info message:\\nprocessing \\\\started -- (.+)$" args: diff --git a/src/snapshots/log2src__tests__extract_logging.snap b/src/snapshots/log2src__tests__extract_logging.snap index 6f7a9034..850a0a5f 100644 --- a/src/snapshots/log2src__tests__extract_logging.snap +++ b/src/snapshots/log2src__tests__extract_logging.snap @@ -9,6 +9,7 @@ expression: src_refs column: 11 name: main text: "\"you're only as funky as your last cut\"" + level: Debug quality: 30 pattern: "(?s)^you're only as funky as your last cut$" args: [] @@ -20,6 +21,7 @@ expression: src_refs column: 16 name: nope text: "\"this won't match i={}; j={}\"" + level: Debug quality: 19 pattern: "(?s)^this won't match i=(.+); j=(.+)$" args: @@ -35,6 +37,7 @@ expression: src_refs column: 11 name: namedarg0 text: "\"{salutation}, {name}!\"" + level: Debug quality: 2 pattern: "(?s)^(.+), (.+)!$" args: @@ -48,6 +51,7 @@ expression: src_refs column: 11 name: namedarg text: "\"Hello, {name}!\"" + level: Debug quality: 7 pattern: "(?s)^Hello, (.+)!$" args: @@ -60,6 +64,7 @@ expression: src_refs column: 11 name: namedarg2 text: "\"{salutation}, {name}!\"" + level: Debug quality: 2 pattern: "(?s)^(.+), (.+)!$" args: diff --git a/src/snapshots/log2src__tests__link_to_quality_source.snap b/src/snapshots/log2src__tests__link_to_quality_source.snap index 4b764a4b..acac0a62 100644 --- a/src/snapshots/log2src__tests__link_to_quality_source.snap +++ b/src/snapshots/log2src__tests__link_to_quality_source.snap @@ -9,6 +9,7 @@ endLineNumber: 27 column: 11 name: namedarg text: "\"Hello, {name}!\"" +level: Debug quality: 7 pattern: "(?s)^Hello, (.+)!$" args: diff --git a/src/source_query.rs b/src/source_query.rs index 3a62315b..9583709d 100644 --- a/src/source_query.rs +++ b/src/source_query.rs @@ -19,6 +19,9 @@ pub(crate) struct QueryResult { pub pattern: Option, pub args: Vec, pub raw: bool, + /// The text of the last capture before the format string, which is the name of the logging + /// function/macro/method being called (e.g. `log::warn`, `logger.info`). + pub call_name: Option, } impl<'a> SourceQuery<'a> { @@ -46,6 +49,7 @@ impl<'a> SourceQuery<'a> { let matches = cursor.matches(&query, self.tree.root_node(), self.source.as_bytes()); matches.for_each(|m| { let mut got_string_literal = false; + let mut call_name: Option = None; for capture in m.captures { let mut child = capture.node; match child.kind() { @@ -56,6 +60,10 @@ impl<'a> SourceQuery<'a> { } _ => { if !got_string_literal { + // remember the name of the logging call so the log level can be + // derived from it. + let range = capture.node.byte_range(); + call_name = Some(self.source[range].to_string()); continue; } } @@ -71,6 +79,7 @@ impl<'a> SourceQuery<'a> { pattern: None, args: vec![], raw: false, + call_name: call_name.clone(), }); let mut pattern = String::new(); if child.kind() == "string" { @@ -118,6 +127,7 @@ impl<'a> SourceQuery<'a> { pattern: None, args: vec![], raw: false, + call_name: None, }); } } diff --git a/src/source_ref.rs b/src/source_ref.rs index 292e1576..7549b255 100644 --- a/src/source_ref.rs +++ b/src/source_ref.rs @@ -11,6 +11,43 @@ pub enum FormatArgument { Placeholder, } +/// The normalized level of a log statement, derived from the name of the logging call. +#[derive(Copy, Clone, Debug, Serialize, Eq, PartialEq)] +pub enum LogLevel { + Trace, + Debug, + Info, + Warn, + Error, + Fatal, +} + +impl LogLevel { + /// Map the name of a logging function/macro/method, like `log::warn`, `logger.warning`, + /// `spdlog::info`, or `SPDLOG_ERROR`, to a level. Returns `None` for calls that don't + /// indicate a level, like `println` or `printf`. + pub fn from_call_name(call_name: &str) -> Option { + let name = call_name + .rsplit(|c: char| !(c.is_alphanumeric() || c == '_')) + .find(|s| !s.is_empty())?; + // Macros like `SPDLOG_INFO` or `LOG_ERROR` put the level at the end. + let name = if name.chars().all(|c| !c.is_lowercase()) { + name.rsplit('_').next().unwrap_or(name) + } else { + name + }; + match name.to_lowercase().as_str() { + "trace" | "finer" | "finest" => Some(LogLevel::Trace), + "debug" | "fine" => Some(LogLevel::Debug), + "info" | "notice" => Some(LogLevel::Info), + "warn" | "warning" => Some(LogLevel::Warn), + "error" | "exception" | "severe" => Some(LogLevel::Error), + "fatal" | "critical" => Some(LogLevel::Fatal), + _ => None, + } + } +} + #[derive(Clone, Debug, Serialize)] pub struct CallSite { pub name: String, @@ -34,6 +71,8 @@ pub struct SourceRef { pub column: usize, pub name: String, pub text: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub level: Option, pub quality: usize, #[serde(skip_serializing)] pub(crate) matcher: Regex, @@ -75,6 +114,10 @@ impl SourceRef { }) = build_matcher(result.raw, &unquoted, code.info.language) { let name = source[result.name_range].to_string(); + let level = result + .call_name + .as_deref() + .and_then(LogLevel::from_call_name); if !result.args.is_empty() { args = result.args; } @@ -86,6 +129,7 @@ impl SourceRef { column: col, name, text, + level, quality, matcher, pattern, @@ -231,6 +275,32 @@ fn escape_ignore_newlines(raw: bool, segment: &str) -> String { mod tests { use super::*; + #[test] + fn test_log_level_from_call_name() { + let cases = [ + ("debug", Some(LogLevel::Debug)), + ("log::warn", Some(LogLevel::Warn)), + ("tracing::trace", Some(LogLevel::Trace)), + ("error", Some(LogLevel::Error)), + ("info", Some(LogLevel::Info)), + ("spdlog::info", Some(LogLevel::Info)), + ("spdlog::critical", Some(LogLevel::Fatal)), + ("logger->warn", Some(LogLevel::Warn)), + ("SPDLOG_ERROR", Some(LogLevel::Error)), + ("logger.warning", Some(LogLevel::Warn)), + ("logging.exception", Some(LogLevel::Error)), + ("self.log.critical", Some(LogLevel::Fatal)), + ("fine", Some(LogLevel::Debug)), + ("println", None), + ("printf", None), + ("print", None), + ("report_error", None), + ]; + for (call_name, expected) in cases { + assert_eq!(LogLevel::from_call_name(call_name), expected, "{call_name}"); + } + } + #[test] fn test_build_matcher_needs_escape() { let MessageMatcher { diff --git a/tests/snapshots/test_java__basic.snap b/tests/snapshots/test_java__basic.snap index 82f4caa4..3d145775 100644 --- a/tests/snapshots/test_java__basic.snap +++ b/tests/snapshots/test_java__basic.snap @@ -13,9 +13,9 @@ info: success: true exit_code: 0 ----- stdout ----- -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":16,"name":"main","text":"\"Hello from main\"","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":16,"name":"main","text":"\"Hello from main\"","level":"Debug","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]} ----- stderr ----- diff --git a/tests/snapshots/test_java__basic_invalid_utf.snap b/tests/snapshots/test_java__basic_invalid_utf.snap index e316d39e..c8e92a09 100644 --- a/tests/snapshots/test_java__basic_invalid_utf.snap +++ b/tests/snapshots/test_java__basic_invalid_utf.snap @@ -13,10 +13,10 @@ info: success: true exit_code: 0 ----- stdout ----- -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":16,"name":"main","text":"\"Hello from main\"","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":16,"name":"main","text":"\"Hello from main\"","level":"Debug","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} {"error":{"message":"unable to read line 2","source":"stream did not contain valid UTF-8"}} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]} ----- stderr ----- diff --git a/tests/snapshots/test_java__basic_range.snap b/tests/snapshots/test_java__basic_range.snap index 8a09c8fe..8fb76c52 100644 --- a/tests/snapshots/test_java__basic_range.snap +++ b/tests/snapshots/test_java__basic_range.snap @@ -17,7 +17,7 @@ info: success: true exit_code: 0 ----- stdout ----- -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/Basic.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} ----- stderr ----- diff --git a/tests/snapshots/test_java__basic_slf4j.snap b/tests/snapshots/test_java__basic_slf4j.snap index 20951dd3..44fb8333 100644 --- a/tests/snapshots/test_java__basic_slf4j.snap +++ b/tests/snapshots/test_java__basic_slf4j.snap @@ -13,7 +13,7 @@ info: success: true exit_code: 0 ----- stdout ----- -{"logRef":{"details":{"thread":"123"}},"srcRef":{"sourcePath":"{java_dir}/BasicSlf4j.java","language":"Java","lineNumber":10,"endLineNumber":10,"column":20,"name":"main","text":"\"Application starting\"","quality":19,"pattern":"(?s)^Application starting$","args":[],"vars":[]},"variables":[]} -{"logRef":{"details":{"thread":"123"}},"srcRef":{"sourcePath":"{java_dir}/BasicSlf4j.java","language":"Java","lineNumber":12,"endLineNumber":13,"column":21,"name":"main","text":"\"Debug message: args length = {}\"","quality":24,"pattern":"(?s)^Debug message: args length = (.+)$","args":["Placeholder"],"vars":["args.length"]},"variables":[{"expr":"args.length","value":"0"}]} +{"logRef":{"details":{"thread":"123"}},"srcRef":{"sourcePath":"{java_dir}/BasicSlf4j.java","language":"Java","lineNumber":10,"endLineNumber":10,"column":20,"name":"main","text":"\"Application starting\"","level":"Info","quality":19,"pattern":"(?s)^Application starting$","args":[],"vars":[]},"variables":[]} +{"logRef":{"details":{"thread":"123"}},"srcRef":{"sourcePath":"{java_dir}/BasicSlf4j.java","language":"Java","lineNumber":12,"endLineNumber":13,"column":21,"name":"main","text":"\"Debug message: args length = {}\"","level":"Debug","quality":24,"pattern":"(?s)^Debug message: args length = (.+)$","args":["Placeholder"],"vars":["args.length"]},"variables":[{"expr":"args.length","value":"0"}]} ----- stderr ----- diff --git a/tests/snapshots/test_java__basic_with_log.snap b/tests/snapshots/test_java__basic_with_log.snap index 23000108..351d1af8 100644 --- a/tests/snapshots/test_java__basic_with_log.snap +++ b/tests/snapshots/test_java__basic_with_log.snap @@ -13,9 +13,9 @@ info: success: true exit_code: 0 ----- stdout ----- -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":13,"name":"main","text":"\"Hello from main\"","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":13,"name":"main","text":"\"Hello from main\"","level":"Debug","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithLog.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":17,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]} ----- stderr ----- diff --git a/tests/snapshots/test_java__basic_with_log_format.snap b/tests/snapshots/test_java__basic_with_log_format.snap index 42a4eeb2..f3f60414 100644 --- a/tests/snapshots/test_java__basic_with_log_format.snap +++ b/tests/snapshots/test_java__basic_with_log_format.snap @@ -13,9 +13,9 @@ info: success: true exit_code: 0 ----- stdout ----- -{"logRef":{"details":{"file":"BasicWithCustom","lineno":15}},"srcRef":{"sourcePath":"{java_dir}/BasicWithCustom.java","language":"Java","lineNumber":15,"endLineNumber":15,"column":16,"name":"main","text":"\"Hello from main\"","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} -{"logRef":{"details":{"file":"BasicWithCustom","lineno":22}},"srcRef":{"sourcePath":"{java_dir}/BasicWithCustom.java","language":"Java","lineNumber":22,"endLineNumber":22,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} -{"logRef":{"details":{"file":"BasicWithCustom","lineno":22}},"srcRef":{"sourcePath":"{java_dir}/BasicWithCustom.java","language":"Java","lineNumber":22,"endLineNumber":22,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} -{"logRef":{"details":{"file":"BasicWithCustom","lineno":22}},"srcRef":{"sourcePath":"{java_dir}/BasicWithCustom.java","language":"Java","lineNumber":22,"endLineNumber":22,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]} +{"logRef":{"details":{"file":"BasicWithCustom","lineno":15}},"srcRef":{"sourcePath":"{java_dir}/BasicWithCustom.java","language":"Java","lineNumber":15,"endLineNumber":15,"column":16,"name":"main","text":"\"Hello from main\"","level":"Debug","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} +{"logRef":{"details":{"file":"BasicWithCustom","lineno":22}},"srcRef":{"sourcePath":"{java_dir}/BasicWithCustom.java","language":"Java","lineNumber":22,"endLineNumber":22,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} +{"logRef":{"details":{"file":"BasicWithCustom","lineno":22}},"srcRef":{"sourcePath":"{java_dir}/BasicWithCustom.java","language":"Java","lineNumber":22,"endLineNumber":22,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} +{"logRef":{"details":{"file":"BasicWithCustom","lineno":22}},"srcRef":{"sourcePath":"{java_dir}/BasicWithCustom.java","language":"Java","lineNumber":22,"endLineNumber":22,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]} ----- stderr ----- diff --git a/tests/snapshots/test_java__basic_with_upper.snap b/tests/snapshots/test_java__basic_with_upper.snap index e6893a70..964aeb84 100644 --- a/tests/snapshots/test_java__basic_with_upper.snap +++ b/tests/snapshots/test_java__basic_with_upper.snap @@ -13,9 +13,9 @@ info: success: true exit_code: 0 ----- stdout ----- -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithUpper.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":16,"name":"main","text":"\"Hello from main\"","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithUpper.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithUpper.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} -{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithUpper.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithUpper.java","language":"Java","lineNumber":18,"endLineNumber":18,"column":16,"name":"main","text":"\"Hello from main\"","level":"Debug","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithUpper.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"0"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithUpper.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"1"}]} +{"logRef":{},"srcRef":{"sourcePath":"{java_dir}/BasicWithUpper.java","language":"Java","lineNumber":25,"endLineNumber":25,"column":20,"name":"foo","text":"\"Hello from foo i=\\{i}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":[{"Named":"i"}],"vars":[]},"variables":[{"expr":"i","value":"2"}]} ----- stderr ----- diff --git a/tests/snapshots/test_rust__basic.snap b/tests/snapshots/test_rust__basic.snap index 789b36ab..ab71e644 100644 --- a/tests/snapshots/test_rust__basic.snap +++ b/tests/snapshots/test_rust__basic.snap @@ -13,11 +13,11 @@ info: success: true exit_code: 0 ----- stdout ----- -{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":6,"endLineNumber":6,"column":11,"name":"main","text":"\"Hello from main\"","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} -{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":15,"endLineNumber":15,"column":11,"name":"foo","text":"\"Hello from foo i={}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":["Placeholder"],"vars":["i"]},"variables":[{"expr":"i","value":"0"}]} -{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":15,"endLineNumber":15,"column":11,"name":"foo","text":"\"Hello from foo i={}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":["Placeholder"],"vars":["i"]},"variables":[{"expr":"i","value":"1"}]} -{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":15,"endLineNumber":15,"column":11,"name":"foo","text":"\"Hello from foo i={}\"","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":["Placeholder"],"vars":["i"]},"variables":[{"expr":"i","value":"2"}]} -{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":18,"endLineNumber":18,"column":24,"name":"bar","text":"\"Hello from bar j={j}\"","quality":14,"pattern":"(?s)^Hello from bar j=(.+)$","args":[{"Named":"j"}],"vars":[]},"variables":[{"expr":"j","value":"4"}]} -{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":20,"endLineNumber":20,"column":32,"name":"baz","text":"\"Hello from baz i={1} j={0}\"","quality":16,"pattern":"(?s)^Hello from baz i=(.+) j=(.+)$","args":[{"Positional":1},{"Positional":0}],"vars":["j","i"]},"variables":[{"expr":"i","value":"5"},{"expr":"j","value":"6"}]} +{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":6,"endLineNumber":6,"column":11,"name":"main","text":"\"Hello from main\"","level":"Debug","quality":13,"pattern":"(?s)^Hello from main$","args":[],"vars":[]},"variables":[]} +{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":15,"endLineNumber":15,"column":11,"name":"foo","text":"\"Hello from foo i={}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":["Placeholder"],"vars":["i"]},"variables":[{"expr":"i","value":"0"}]} +{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":15,"endLineNumber":15,"column":11,"name":"foo","text":"\"Hello from foo i={}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":["Placeholder"],"vars":["i"]},"variables":[{"expr":"i","value":"1"}]} +{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":15,"endLineNumber":15,"column":11,"name":"foo","text":"\"Hello from foo i={}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from foo i=(.+)$","args":["Placeholder"],"vars":["i"]},"variables":[{"expr":"i","value":"2"}]} +{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":18,"endLineNumber":18,"column":24,"name":"bar","text":"\"Hello from bar j={j}\"","level":"Debug","quality":14,"pattern":"(?s)^Hello from bar j=(.+)$","args":[{"Named":"j"}],"vars":[]},"variables":[{"expr":"j","value":"4"}]} +{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/basic.rs","language":"Rust","lineNumber":20,"endLineNumber":20,"column":32,"name":"baz","text":"\"Hello from baz i={1} j={0}\"","level":"Debug","quality":16,"pattern":"(?s)^Hello from baz i=(.+) j=(.+)$","args":[{"Positional":1},{"Positional":0}],"vars":["j","i"]},"variables":[{"expr":"i","value":"5"},{"expr":"j","value":"6"}]} ----- stderr ----- diff --git a/tests/snapshots/test_rust__stack.snap b/tests/snapshots/test_rust__stack.snap index 533565cf..e1ef0457 100644 --- a/tests/snapshots/test_rust__stack.snap +++ b/tests/snapshots/test_rust__stack.snap @@ -15,6 +15,6 @@ info: success: true exit_code: 0 ----- stdout ----- -{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/stack.rs","language":"Rust","lineNumber":15,"endLineNumber":15,"column":11,"name":"b","text":"\"Hello from b\"","quality":10,"pattern":"(?s)^Hello from b$","args":[],"vars":[]},"variables":[]} +{"logRef":{},"srcRef":{"sourcePath":"{example_dir}/stack.rs","language":"Rust","lineNumber":15,"endLineNumber":15,"column":11,"name":"b","text":"\"Hello from b\"","level":"Debug","quality":10,"pattern":"(?s)^Hello from b$","args":[],"vars":[]},"variables":[]} ----- stderr ----- From 8951551d4fe897ff77111b9d821024284309ade3 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 20:16:07 +0000 Subject: [PATCH 5/8] Derive log levels from C/C++ names like log_debug and level arguments Names made of words joined by underscores, like lnav's log_debug() or SPDLOG_INFO, map to the level at the end as long as another word looks logging related. Arguments before the format string, as in syslog(LOG_ERR, ...) or spdlog::log(spdlog::level::warn, ...), are also checked for a level and take precedence over the call name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014JycCPeVRxuveoeMtHnJmt --- src/lib.rs | 10 ++++++++ src/source_query.rs | 18 ++++++++++++++ src/source_ref.rs | 59 +++++++++++++++++++++++++++++---------------- 3 files changed, 66 insertions(+), 21 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 907ae4b0..42f12c26 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1234,6 +1234,11 @@ int main(int argc, char* argv[]) { logger->warn("warn message"); SPDLOG_ERROR("error message"); spdlog::critical("critical message"); + log_debug("lnav debug message"); + log_warning("lnav warn message"); + syslog(LOG_ERR, "syslog error message"); + spdlog::log(spdlog::level::trace, "spdlog trace message"); + report_error("not a log message"); printf("plain message"); } "#, @@ -1245,6 +1250,11 @@ int main(int argc, char* argv[]) { ("\"warn message\"".to_string(), Some(LogLevel::Warn)), ("\"error message\"".to_string(), Some(LogLevel::Error)), ("\"critical message\"".to_string(), Some(LogLevel::Fatal)), + ("\"lnav debug message\"".to_string(), Some(LogLevel::Debug)), + ("\"lnav warn message\"".to_string(), Some(LogLevel::Warn)), + ("\"syslog error message\"".to_string(), Some(LogLevel::Error)), + ("\"spdlog trace message\"".to_string(), Some(LogLevel::Trace)), + ("\"not a log message\"".to_string(), None), ("\"plain message\"".to_string(), None), ] ); diff --git a/src/source_query.rs b/src/source_query.rs index 9583709d..a0424533 100644 --- a/src/source_query.rs +++ b/src/source_query.rs @@ -22,6 +22,9 @@ pub(crate) struct QueryResult { /// The text of the last capture before the format string, which is the name of the logging /// function/macro/method being called (e.g. `log::warn`, `logger.info`). pub call_name: Option, + /// The text of the arguments before the format string, which might include the log level, + /// as in `syslog(LOG_ERR, "...")`. + pub leading_args: Vec, } impl<'a> SourceQuery<'a> { @@ -80,6 +83,7 @@ impl<'a> SourceQuery<'a> { args: vec![], raw: false, call_name: call_name.clone(), + leading_args: self.leading_args(child), }); let mut pattern = String::new(); if child.kind() == "string" { @@ -128,6 +132,7 @@ impl<'a> SourceQuery<'a> { args: vec![], raw: false, call_name: None, + leading_args: vec![], }); } } @@ -142,6 +147,19 @@ impl<'a> SourceQuery<'a> { results } + fn leading_args(&self, node: Node) -> Vec { + let mut args = Vec::new(); + if node.parent().is_some_and(|p| p.kind() == "argument_list") { + let mut prev = node.prev_named_sibling(); + while let Some(arg) = prev { + args.push(self.source[arg.byte_range()].to_string()); + prev = arg.prev_named_sibling(); + } + args.reverse(); + } + args + } + fn find_fn_range(node: Node) -> Range { // println!("node.kind()={:?}", node.kind()); match node.kind() { diff --git a/src/source_ref.rs b/src/source_ref.rs index 7549b255..5701da58 100644 --- a/src/source_ref.rs +++ b/src/source_ref.rs @@ -24,25 +24,32 @@ pub enum LogLevel { impl LogLevel { /// Map the name of a logging function/macro/method, like `log::warn`, `logger.warning`, - /// `spdlog::info`, or `SPDLOG_ERROR`, to a level. Returns `None` for calls that don't - /// indicate a level, like `println` or `printf`. - pub fn from_call_name(call_name: &str) -> Option { - let name = call_name + /// `spdlog::info`, `log_debug`, or `SPDLOG_ERROR`, to a level. This is also used for + /// level arguments, like `LOG_ERR` or `spdlog::level::warn`. Returns `None` for names that + /// don't indicate a level, like `println` or `printf`. + pub fn from_name(name: &str) -> Option { + let name = name .rsplit(|c: char| !(c.is_alphanumeric() || c == '_')) - .find(|s| !s.is_empty())?; - // Macros like `SPDLOG_INFO` or `LOG_ERROR` put the level at the end. - let name = if name.chars().all(|c| !c.is_lowercase()) { - name.rsplit('_').next().unwrap_or(name) - } else { - name - }; - match name.to_lowercase().as_str() { + .find(|s| !s.is_empty())? + .to_lowercase(); + let mut words = name.split('_').filter(|s| !s.is_empty()); + let last = words.next_back()?; + // For names like `log_debug`, `SPDLOG_INFO`, or `LOG_ERR`, the level is at the end and + // one of the other words needs to look logging related so that something like + // `report_error` is not treated as a log statement. + let mut prefix = words.peekable(); + if prefix.peek().is_some() + && !prefix.any(|w| w.contains("log") || w == "level" || w == "lvl") + { + return None; + } + match last { "trace" | "finer" | "finest" => Some(LogLevel::Trace), - "debug" | "fine" => Some(LogLevel::Debug), + "debug" | "dbg" | "fine" => Some(LogLevel::Debug), "info" | "notice" => Some(LogLevel::Info), "warn" | "warning" => Some(LogLevel::Warn), - "error" | "exception" | "severe" => Some(LogLevel::Error), - "fatal" | "critical" => Some(LogLevel::Fatal), + "error" | "err" | "exception" | "severe" => Some(LogLevel::Error), + "fatal" | "critical" | "crit" | "alert" | "emerg" => Some(LogLevel::Fatal), _ => None, } } @@ -114,10 +121,13 @@ impl SourceRef { }) = build_matcher(result.raw, &unquoted, code.info.language) { let name = source[result.name_range].to_string(); + // A level passed as an argument, like `syslog(LOG_ERR, ...)`, takes precedence + // over the name of the call. let level = result - .call_name - .as_deref() - .and_then(LogLevel::from_call_name); + .leading_args + .iter() + .chain(result.call_name.iter()) + .find_map(|name| LogLevel::from_name(name)); if !result.args.is_empty() { args = result.args; } @@ -276,7 +286,7 @@ mod tests { use super::*; #[test] - fn test_log_level_from_call_name() { + fn test_log_level_from_name() { let cases = [ ("debug", Some(LogLevel::Debug)), ("log::warn", Some(LogLevel::Warn)), @@ -291,13 +301,20 @@ mod tests { ("logging.exception", Some(LogLevel::Error)), ("self.log.critical", Some(LogLevel::Fatal)), ("fine", Some(LogLevel::Debug)), + ("log_debug", Some(LogLevel::Debug)), + ("log_warning", Some(LogLevel::Warn)), + ("LOG_ERR", Some(LogLevel::Error)), + ("LOG_CRIT", Some(LogLevel::Fatal)), + ("APLOG_MARK", None), + ("spdlog::level::warn", Some(LogLevel::Warn)), + ("LEVEL_INFO", Some(LogLevel::Info)), ("println", None), ("printf", None), ("print", None), ("report_error", None), ]; - for (call_name, expected) in cases { - assert_eq!(LogLevel::from_call_name(call_name), expected, "{call_name}"); + for (name, expected) in cases { + assert_eq!(LogLevel::from_name(name), expected, "{name}"); } } From 07dd371d77c5046fff992503f7e95f192631c541 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 22:49:37 +0000 Subject: [PATCH 6/8] Tighten log level detection and use it when linking - Only take level constants from leading arguments that are names, so a string literal like "conn error" no longer sets the level of a later string in the same call. - Require leading arguments to be qualified by something logging related (LOG_ERR, logging.WARNING, spdlog::level::warn), so a variable like `err` no longer overrides the call name. - Require the qualifier of a call like `x.error` to look logging related, so warnings.warn and parser.error don't get a level. - Split names on case changes so logInfo and LogWarning are recognized. - Let the Java query match severe/fatal and log.log(Level.X, ...). - Match level-first calls in Python (logger.log(logging.WARNING, ...)) and Rust (log!(Level::Warn, ...), tracing::event!(Level::INFO, ...)). - Parse the level from log messages and prefer source statements with the same level when candidates have the same quality. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VN7ZsUqHrHHcBHoxTnSpjb --- src/lib.rs | 140 +++++++++++++++++++++++++++++++++++++++-- src/source_query.rs | 47 ++++++++++++-- src/source_ref.rs | 147 ++++++++++++++++++++++++++++++++++++++------ 3 files changed, 304 insertions(+), 30 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 42f12c26..555cbdfe 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -254,7 +254,7 @@ impl LogMatcher { }; if let Some(src_ref) = matches .iter() - .sorted_by(|lhs, rhs| rhs.quality.cmp(&lhs.quality)) + .sorted_by(|lhs, rhs| log_ref.compare_candidates(lhs, rhs)) .next() { let exception_trace = match log_ref { @@ -419,6 +419,27 @@ impl SourceLanguage { ) (#not-any-of? @macro-name "format" "vec") ) + ; macros that take the level before the format string, like + ; `log!(Level::Warn, "...")` or `tracing::event!(Level::INFO, "...")` + (macro_invocation macro: (_) @macro-name + (token_tree . + (identifier) @_level . (string_literal) @log + ) + (#match? @macro-name "(^|::)(log|event)$") + (#not-eq? @_level "target") + ) + (macro_invocation macro: (_) @macro-name + (token_tree . + (identifier) . (identifier) . (string_literal) @log + ) + (#match? @macro-name "(^|::)(log|event)$") + ) + (macro_invocation macro: (_) @macro-name + (token_tree . + (identifier) . (identifier) . (identifier) . (string_literal) @log + ) + (#match? @macro-name "(^|::)(log|event)$") + ) "# } SourceLanguage::Java => { @@ -432,7 +453,15 @@ impl SourceLanguage { (argument_list . (string_literal) @arguments) ] (#match? @object-name "log(ger)?|LOG(GER)?") - (#match? @method-name "fine|debug|info|warn|trace|error") + (#match? @method-name "fine|debug|info|warn|trace|error|severe|fatal") + ) + ; `log.log(Level.WARNING, "...")` + (method_invocation + object: (identifier) @object-name + name: (identifier) @method-name + arguments: (argument_list . (field_access) . (string_literal) @arguments) + (#match? @object-name "log(ger)?|LOG(GER)?") + (#eq? @method-name "log") ) "# } @@ -463,6 +492,18 @@ impl SourceLanguage { ) ) ) + ; `logger.log(logging.WARNING, "...")` + ( + (expression_statement + (call + function: (_) @func + arguments: (argument_list . + (_) . (string) @args + ) + ) + ) + (#match? @func "(^|\\.)log$") + ) "# } } @@ -632,6 +673,8 @@ pub struct LogDetails<'a> { #[serde(skip_serializing_if = "Option::is_none")] pub lineno: Option, #[serde(skip_serializing)] + pub level: Option, + #[serde(skip_serializing)] pub body: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] pub trace: Option>, @@ -642,6 +685,7 @@ impl<'a> LogDetails<'a> { self.thread.is_none() && self.file.is_none() && self.lineno.is_none() + && self.level.is_none() && self.body.is_none() && self.trace.is_none() } @@ -666,6 +710,7 @@ impl<'a> LogRefBuilder<'a> { .map(|m| m.as_str().parse::().unwrap_or_default()), ) .with_thread(captures.name("thread").map(|m| m.as_str())) + .with_level(captures.name("level").map(|m| m.as_str())) .with_body(captures.name("body").map(|m| m.as_str())) .build(content) } @@ -682,6 +727,10 @@ impl<'a> LogRefBuilder<'a> { self.details.lineno = lineno; self } + pub fn with_level(mut self, level: Option<&'a str>) -> Self { + self.details.level = level.and_then(LogLevel::from_log_text); + self + } pub fn with_body(mut self, body: Option<&'a str>) -> Self { let (body, trace) = if let Some(body) = body { @@ -730,12 +779,27 @@ impl<'a> LogRef<'a> { self.line } } + + pub fn level(&self) -> Option { + self.details.and_then(|details| details.level) + } + + /// Order candidate source statements for this message from best to worst. The highest + /// quality wins and, when the quality is the same, a statement with the same level as the + /// message is preferred. + fn compare_candidates(&self, lhs: &SourceRef, rhs: &SourceRef) -> std::cmp::Ordering { + let level = self.level(); + let level_matches = |src_ref: &SourceRef| level.is_some() && src_ref.level == level; + rhs.quality + .cmp(&lhs.quality) + .then_with(|| level_matches(rhs).cmp(&level_matches(lhs))) + } } pub fn link_to_source<'a>(log_ref: &LogRef, src_refs: &'a [SourceRef]) -> Option<&'a SourceRef> { src_refs .iter() - .sorted_by(|lhs, rhs| rhs.quality.cmp(&lhs.quality)) + .sorted_by(|lhs, rhs| log_ref.compare_candidates(lhs, rhs)) .find(|&source_ref| source_ref.captures(log_ref.body()).is_some()) } @@ -881,6 +945,7 @@ mod tests { thread: None, file: Some("JvmPauseMonitor"), lineno: Some(146), + level: Some(LogLevel::Info), body: Some("JvmPauseMonitor-n0: Started"), trace: None, }); @@ -1180,6 +1245,10 @@ fn main() { log::warn!("warn message"); tracing::error!("error message"); println!("plain message"); + log::log!(Level::Warn, "log macro message"); + tracing::event!(tracing::Level::INFO, "event macro message"); + log!(lvl, "level variable message"); + log!(target: "app", "target message"); } "#, ); @@ -1192,6 +1261,9 @@ fn main() { ("\"warn message\"".to_string(), Some(LogLevel::Warn)), ("\"error message\"".to_string(), Some(LogLevel::Error)), ("\"plain message\"".to_string(), None), + ("\"log macro message\"".to_string(), Some(LogLevel::Warn)), + ("\"event macro message\"".to_string(), Some(LogLevel::Info)), + ("\"level variable message\"".to_string(), None), ] ); } @@ -1208,6 +1280,10 @@ class Main { logger.info("info message"); LOGGER.warn("warn message"); LOG.error("error message"); + log.severe("severe message"); + LOG.fatal("fatal message"); + log.finest("finest message"); + log.log(Level.WARNING, "level arg message"); } } "#, @@ -1220,6 +1296,10 @@ class Main { ("\"info message\"".to_string(), Some(LogLevel::Info)), ("\"warn message\"".to_string(), Some(LogLevel::Warn)), ("\"error message\"".to_string(), Some(LogLevel::Error)), + ("\"severe message\"".to_string(), Some(LogLevel::Error)), + ("\"fatal message\"".to_string(), Some(LogLevel::Fatal)), + ("\"finest message\"".to_string(), Some(LogLevel::Trace)), + ("\"level arg message\"".to_string(), Some(LogLevel::Warn)), ] ); } @@ -1240,6 +1320,10 @@ int main(int argc, char* argv[]) { spdlog::log(spdlog::level::trace, "spdlog trace message"); report_error("not a log message"); printf("plain message"); + log_info("conn error", "second message"); + logger->info(err, "variable arg message"); + logInfo("camel message"); + errors.error("not a log message either"); } "#, ); @@ -1252,10 +1336,21 @@ int main(int argc, char* argv[]) { ("\"critical message\"".to_string(), Some(LogLevel::Fatal)), ("\"lnav debug message\"".to_string(), Some(LogLevel::Debug)), ("\"lnav warn message\"".to_string(), Some(LogLevel::Warn)), - ("\"syslog error message\"".to_string(), Some(LogLevel::Error)), - ("\"spdlog trace message\"".to_string(), Some(LogLevel::Trace)), + ( + "\"syslog error message\"".to_string(), + Some(LogLevel::Error) + ), + ( + "\"spdlog trace message\"".to_string(), + Some(LogLevel::Trace) + ), ("\"not a log message\"".to_string(), None), ("\"plain message\"".to_string(), None), + ("\"conn error\"".to_string(), Some(LogLevel::Info)), + ("\"second message\"".to_string(), Some(LogLevel::Info)), + ("\"variable arg message\"".to_string(), Some(LogLevel::Info)), + ("\"camel message\"".to_string(), Some(LogLevel::Info)), + ("\"not a log message either\"".to_string(), None), ] ); } @@ -1273,6 +1368,10 @@ def main(): logger.exception("exception message") logger.critical("critical message") print("plain message") + warnings.warn("deprecated message") + parser.error("bad arg message") + logger.log(logging.WARNING, "level arg message") + logging.log(level, "level variable message") "#, ); assert_eq!( @@ -1285,10 +1384,41 @@ def main(): ("\"exception message\"".to_string(), Some(LogLevel::Error)), ("\"critical message\"".to_string(), Some(LogLevel::Fatal)), ("\"plain message\"".to_string(), None), + ("\"deprecated message\"".to_string(), None), + ("\"bad arg message\"".to_string(), None), + ("\"level arg message\"".to_string(), Some(LogLevel::Warn)), + ("\"level variable message\"".to_string(), None), ] ); } + #[test] + fn test_link_to_source_by_level() { + let code = CodeSource::from_string( + &Path::new("in-mem.rs"), + r#" +fn main() { + debug!("retry"); + warn!("retry"); +} +"#, + ); + let src_refs = extract_logging(&[code], &ProgressTracker::new()) + .pop() + .unwrap() + .log_statements; + let log_format: LogFormat = r"^(?\w+) (?.*)$".try_into().unwrap(); + for (line, expected) in [ + ("DEBUG retry", LogLevel::Debug), + ("WARN retry", LogLevel::Warn), + ] { + let log_ref = + LogRefBuilder::new().build_from_captures(log_format.captures(line).unwrap(), line); + let result = link_to_source(&log_ref, &src_refs).unwrap(); + assert_eq!(result.level, Some(expected), "{line}"); + } + } + const TRACE: &str = r#"JvmPauseMonitor-n0: Started java.lang.IllegalStateException: simulated failure for demo at org.example.Main.simulateError(Main.java:50) diff --git a/src/source_query.rs b/src/source_query.rs index a0424533..a6b6b353 100644 --- a/src/source_query.rs +++ b/src/source_query.rs @@ -54,6 +54,10 @@ impl<'a> SourceQuery<'a> { let mut got_string_literal = false; let mut call_name: Option = None; for capture in m.captures { + // captures starting with an underscore are only used for predicates + if query.capture_names()[capture.index as usize].starts_with('_') { + continue; + } let mut child = capture.node; match child.kind() { "string_literal" | "string" => { @@ -147,15 +151,46 @@ impl<'a> SourceQuery<'a> { results } + /// Collect the arguments before the format string that might be a log level, like the + /// `LOG_ERR` in `syslog(LOG_ERR, "...")` or the `Level::Warn` in `log!(Level::Warn, "...")`. fn leading_args(&self, node: Node) -> Vec { let mut args = Vec::new(); - if node.parent().is_some_and(|p| p.kind() == "argument_list") { - let mut prev = node.prev_named_sibling(); - while let Some(arg) = prev { - args.push(self.source[arg.byte_range()].to_string()); - prev = arg.prev_named_sibling(); + match node.parent().map(|p| p.kind()) { + Some("argument_list") => { + let mut prev = node.prev_named_sibling(); + while let Some(arg) = prev { + // Only names can be level constants, other arguments like string literals + // could contain level words that aren't relevant. + if matches!( + arg.kind(), + "identifier" + | "qualified_identifier" + | "field_expression" + | "field_access" + | "attribute" + ) { + args.push(self.source[arg.byte_range()].to_string()); + } + prev = arg.prev_named_sibling(); + } + args.reverse(); + } + Some("token_tree") => { + // Macro arguments are a flat list of tokens, so split the text before the + // format string on commas. + let parent = node.parent().unwrap(); + let start = parent.start_byte() + 1; + if start < node.start_byte() { + args.extend( + self.source[start..node.start_byte()] + .split(',') + .map(str::trim) + .filter(|arg| !arg.is_empty() && !arg.contains('"')) + .map(str::to_string), + ); + } } - args.reverse(); + _ => {} } args } diff --git a/src/source_ref.rs b/src/source_ref.rs index 5701da58..2b767abf 100644 --- a/src/source_ref.rs +++ b/src/source_ref.rs @@ -24,26 +24,57 @@ pub enum LogLevel { impl LogLevel { /// Map the name of a logging function/macro/method, like `log::warn`, `logger.warning`, - /// `spdlog::info`, `log_debug`, or `SPDLOG_ERROR`, to a level. This is also used for - /// level arguments, like `LOG_ERR` or `spdlog::level::warn`. Returns `None` for names that - /// don't indicate a level, like `println` or `printf`. + /// `spdlog::info`, `log_debug`, `logInfo`, or `SPDLOG_ERROR`, to a level. Returns `None` + /// for names that don't indicate a level, like `println`, `printf`, or `parser.error`. pub fn from_name(name: &str) -> Option { - let name = name - .rsplit(|c: char| !(c.is_alphanumeric() || c == '_')) - .find(|s| !s.is_empty())? - .to_lowercase(); - let mut words = name.split('_').filter(|s| !s.is_empty()); - let last = words.next_back()?; + Self::classify(name, false) + } + + /// Map an argument that is passed to a logging call, like `LOG_ERR`, `logging.WARNING`, or + /// `spdlog::level::warn`, to a level. Unlike [`LogLevel::from_name`], the argument has to + /// be qualified by something logging related, so that a variable like `err` is ignored. + pub fn from_level_arg(arg: &str) -> Option { + Self::classify(arg, true) + } + + /// Map the level text from a log message, like `WARN` or `error`, to a level. + pub fn from_log_text(text: &str) -> Option { + Self::from_word(&text.trim().to_lowercase()) + } + + fn classify(name: &str, require_qualifier: bool) -> Option { + let mut segments: Vec<&str> = name + .split(|c: char| !(c.is_alphanumeric() || c == '_')) + .filter(|s| !s.is_empty()) + .collect(); + let mut words = split_words(segments.pop()?); + let last = words.pop()?; // For names like `log_debug`, `SPDLOG_INFO`, or `LOG_ERR`, the level is at the end and // one of the other words needs to look logging related so that something like // `report_error` is not treated as a log statement. - let mut prefix = words.peekable(); - if prefix.peek().is_some() - && !prefix.any(|w| w.contains("log") || w == "level" || w == "lvl") - { - return None; + if !words.is_empty() { + return if words.iter().any(|w| is_logging_word(w)) { + Self::from_word(&last) + } else { + None + }; + } + // For a qualified name, like `logger.warn` or `spdlog::level::info`, the qualifier + // needs to look logging related so that something like `warnings.warn` or + // `parser.error` is not treated as a log statement. + let qualified = segments + .iter() + .flat_map(|seg| split_words(seg)) + .any(|w| is_logging_word(&w)); + if qualified || (segments.is_empty() && !require_qualifier) { + Self::from_word(&last) + } else { + None } - match last { + } + + fn from_word(word: &str) -> Option { + match word { "trace" | "finer" | "finest" => Some(LogLevel::Trace), "debug" | "dbg" | "fine" => Some(LogLevel::Debug), "info" | "notice" => Some(LogLevel::Info), @@ -55,6 +86,42 @@ impl LogLevel { } } +fn is_logging_word(word: &str) -> bool { + word.contains("log") || matches!(word, "level" | "lvl" | "tracing") +} + +/// Split an identifier into lowercase words on underscores and case changes, so `log_debug`, +/// `logDebug`, `LogDebug`, and `LOG_DEBUG` all become `["log", "debug"]`. +fn split_words(ident: &str) -> Vec { + let chars: Vec = ident.chars().collect(); + let mut words = Vec::new(); + let mut word = String::new(); + for (i, &c) in chars.iter().enumerate() { + if c == '_' { + if !word.is_empty() { + words.push(std::mem::take(&mut word)); + } + continue; + } + if c.is_uppercase() && !word.is_empty() { + let prev = chars[i - 1]; + let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_lowercase()); + // Split `logDebug` before the `D` and `LOGDebug` before the `D`, but not `SPDLOG`. + if prev.is_lowercase() + || prev.is_ascii_digit() + || (prev.is_uppercase() && next_is_lower) + { + words.push(std::mem::take(&mut word)); + } + } + word.extend(c.to_lowercase()); + } + if !word.is_empty() { + words.push(word); + } + words +} + #[derive(Clone, Debug, Serialize)] pub struct CallSite { pub name: String, @@ -121,13 +188,13 @@ impl SourceRef { }) = build_matcher(result.raw, &unquoted, code.info.language) { let name = source[result.name_range].to_string(); - // A level passed as an argument, like `syslog(LOG_ERR, ...)`, takes precedence - // over the name of the call. + // A level constant passed as an argument, like `syslog(LOG_ERR, ...)`, takes + // precedence over the name of the call. let level = result .leading_args .iter() - .chain(result.call_name.iter()) - .find_map(|name| LogLevel::from_name(name)); + .find_map(|arg| LogLevel::from_level_arg(arg)) + .or_else(|| result.call_name.as_deref().and_then(LogLevel::from_name)); if !result.args.is_empty() { args = result.args; } @@ -312,6 +379,48 @@ mod tests { ("printf", None), ("print", None), ("report_error", None), + ("logInfo", Some(LogLevel::Info)), + ("LogWarning", Some(LogLevel::Warn)), + ("m_logger->logError", Some(LogLevel::Error)), + ("LOGError", Some(LogLevel::Error)), + ("reportError", None), + ("tracing::error", Some(LogLevel::Error)), + ("spdlog::get(\"app\")->warn", Some(LogLevel::Warn)), + ("warnings.warn", None), + ("parser.error", None), + ("errors.error", None), + ]; + for (name, expected) in cases { + assert_eq!(LogLevel::from_name(name), expected, "{name}"); + } + } + + #[test] + fn test_log_level_from_level_arg() { + let cases = [ + ("LOG_ERR", Some(LogLevel::Error)), + ("spdlog::level::trace", Some(LogLevel::Trace)), + ("logging.WARNING", Some(LogLevel::Warn)), + ("Level::Warn", Some(LogLevel::Warn)), + ("tracing::Level::INFO", Some(LogLevel::Info)), + ("Level.SEVERE", Some(LogLevel::Error)), + ("err", None), + ("info", None), + ("level", None), + ("errors.error", None), + ]; + for (name, expected) in cases { + assert_eq!(LogLevel::from_level_arg(name), expected, "{name}"); + } + } + + #[test] + fn test_log_level_from_log_text() { + let cases = [ + ("WARN", Some(LogLevel::Warn)), + ("warning", Some(LogLevel::Warn)), + (" ERROR ", Some(LogLevel::Error)), + ("W", None), ]; for (name, expected) in cases { assert_eq!(LogLevel::from_name(name), expected, "{name}"); From 85c891fac6842ba2517876059dd5b5c210dcf304 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 25 Oct 2026 23:56:18 +0000 Subject: [PATCH 7/8] [GitHub] Disable the VS Code extension test job The job fails before any test runs because @vscode/test-electron can't find the Electron binary in the current VS Code macOS download. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014JycCPeVRxuveoeMtHnJmt --- .github/workflows/main.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index e17332fb..174ffe24 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -72,6 +72,8 @@ jobs: vscode-extension-test: name: Test VS Code Extension needs: [lib-build-and-test] + # Disabled: @vscode/test-electron can't launch the current VS Code release on macOS. + if: false runs-on: macos-latest timeout-minutes: 15 From 79a55033e266b82bd568ca0b9fec6d70fcfcbae5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 00:17:48 +0000 Subject: [PATCH 8/8] Give statements an Unknown level instead of an optional one Every extracted statement now has a level. Calls where we can't tell the level, which might not be logging at all (printf, println!, print, or any call that takes a string), get LogLevel::Unknown, and the "level" field is always present in the srcRef output. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014JycCPeVRxuveoeMtHnJmt --- editors/code/src/debugAdapter.ts | 2 +- src/lib.rs | 99 ++++++++++++++++---------------- src/source_ref.rs | 9 ++- 3 files changed, 55 insertions(+), 55 deletions(-) diff --git a/editors/code/src/debugAdapter.ts b/editors/code/src/debugAdapter.ts index 77db0503..96f68abc 100644 --- a/editors/code/src/debugAdapter.ts +++ b/editors/code/src/debugAdapter.ts @@ -41,7 +41,7 @@ interface SourceRef { lineNumber: number, column: number, name: string, - level?: string, + level: string, } interface ILaunchRequestArguments extends DebugProtocol.LaunchRequestArguments { diff --git a/src/lib.rs b/src/lib.rs index 555cbdfe..af4211b0 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -789,7 +789,7 @@ impl<'a> LogRef<'a> { /// message is preferred. fn compare_candidates(&self, lhs: &SourceRef, rhs: &SourceRef) -> std::cmp::Ordering { let level = self.level(); - let level_matches = |src_ref: &SourceRef| level.is_some() && src_ref.level == level; + let level_matches = |src_ref: &SourceRef| level == Some(src_ref.level); rhs.quality .cmp(&lhs.quality) .then_with(|| level_matches(rhs).cmp(&level_matches(lhs))) @@ -1222,7 +1222,7 @@ processing \started -- {args[0]}""") ); } - fn extract_levels(path: &str, source: &str) -> Vec<(String, Option)> { + fn extract_levels(path: &str, source: &str) -> Vec<(String, LogLevel)> { let code = CodeSource::from_string(&Path::new(path), source); extract_logging(&[code], &ProgressTracker::new()) .pop() @@ -1255,15 +1255,15 @@ fn main() { assert_eq!( levels, vec![ - ("\"trace message\"".to_string(), Some(LogLevel::Trace)), - ("\"debug message\"".to_string(), Some(LogLevel::Debug)), - ("\"info message\"".to_string(), Some(LogLevel::Info)), - ("\"warn message\"".to_string(), Some(LogLevel::Warn)), - ("\"error message\"".to_string(), Some(LogLevel::Error)), - ("\"plain message\"".to_string(), None), - ("\"log macro message\"".to_string(), Some(LogLevel::Warn)), - ("\"event macro message\"".to_string(), Some(LogLevel::Info)), - ("\"level variable message\"".to_string(), None), + ("\"trace message\"".to_string(), LogLevel::Trace), + ("\"debug message\"".to_string(), LogLevel::Debug), + ("\"info message\"".to_string(), LogLevel::Info), + ("\"warn message\"".to_string(), LogLevel::Warn), + ("\"error message\"".to_string(), LogLevel::Error), + ("\"plain message\"".to_string(), LogLevel::Unknown), + ("\"log macro message\"".to_string(), LogLevel::Warn), + ("\"event macro message\"".to_string(), LogLevel::Info), + ("\"level variable message\"".to_string(), LogLevel::Unknown), ] ); } @@ -1291,15 +1291,15 @@ class Main { assert_eq!( levels, vec![ - ("\"trace message\"".to_string(), Some(LogLevel::Trace)), - ("\"debug message\"".to_string(), Some(LogLevel::Debug)), - ("\"info message\"".to_string(), Some(LogLevel::Info)), - ("\"warn message\"".to_string(), Some(LogLevel::Warn)), - ("\"error message\"".to_string(), Some(LogLevel::Error)), - ("\"severe message\"".to_string(), Some(LogLevel::Error)), - ("\"fatal message\"".to_string(), Some(LogLevel::Fatal)), - ("\"finest message\"".to_string(), Some(LogLevel::Trace)), - ("\"level arg message\"".to_string(), Some(LogLevel::Warn)), + ("\"trace message\"".to_string(), LogLevel::Trace), + ("\"debug message\"".to_string(), LogLevel::Debug), + ("\"info message\"".to_string(), LogLevel::Info), + ("\"warn message\"".to_string(), LogLevel::Warn), + ("\"error message\"".to_string(), LogLevel::Error), + ("\"severe message\"".to_string(), LogLevel::Error), + ("\"fatal message\"".to_string(), LogLevel::Fatal), + ("\"finest message\"".to_string(), LogLevel::Trace), + ("\"level arg message\"".to_string(), LogLevel::Warn), ] ); } @@ -1330,27 +1330,24 @@ int main(int argc, char* argv[]) { assert_eq!( levels, vec![ - ("\"info message\"".to_string(), Some(LogLevel::Info)), - ("\"warn message\"".to_string(), Some(LogLevel::Warn)), - ("\"error message\"".to_string(), Some(LogLevel::Error)), - ("\"critical message\"".to_string(), Some(LogLevel::Fatal)), - ("\"lnav debug message\"".to_string(), Some(LogLevel::Debug)), - ("\"lnav warn message\"".to_string(), Some(LogLevel::Warn)), + ("\"info message\"".to_string(), LogLevel::Info), + ("\"warn message\"".to_string(), LogLevel::Warn), + ("\"error message\"".to_string(), LogLevel::Error), + ("\"critical message\"".to_string(), LogLevel::Fatal), + ("\"lnav debug message\"".to_string(), LogLevel::Debug), + ("\"lnav warn message\"".to_string(), LogLevel::Warn), + ("\"syslog error message\"".to_string(), LogLevel::Error), + ("\"spdlog trace message\"".to_string(), LogLevel::Trace), + ("\"not a log message\"".to_string(), LogLevel::Unknown), + ("\"plain message\"".to_string(), LogLevel::Unknown), + ("\"conn error\"".to_string(), LogLevel::Info), + ("\"second message\"".to_string(), LogLevel::Info), + ("\"variable arg message\"".to_string(), LogLevel::Info), + ("\"camel message\"".to_string(), LogLevel::Info), ( - "\"syslog error message\"".to_string(), - Some(LogLevel::Error) + "\"not a log message either\"".to_string(), + LogLevel::Unknown ), - ( - "\"spdlog trace message\"".to_string(), - Some(LogLevel::Trace) - ), - ("\"not a log message\"".to_string(), None), - ("\"plain message\"".to_string(), None), - ("\"conn error\"".to_string(), Some(LogLevel::Info)), - ("\"second message\"".to_string(), Some(LogLevel::Info)), - ("\"variable arg message\"".to_string(), Some(LogLevel::Info)), - ("\"camel message\"".to_string(), Some(LogLevel::Info)), - ("\"not a log message either\"".to_string(), None), ] ); } @@ -1377,17 +1374,17 @@ def main(): assert_eq!( levels, vec![ - ("\"debug message\"".to_string(), Some(LogLevel::Debug)), - ("\"info message\"".to_string(), Some(LogLevel::Info)), - ("\"warn message\"".to_string(), Some(LogLevel::Warn)), - ("\"error message\"".to_string(), Some(LogLevel::Error)), - ("\"exception message\"".to_string(), Some(LogLevel::Error)), - ("\"critical message\"".to_string(), Some(LogLevel::Fatal)), - ("\"plain message\"".to_string(), None), - ("\"deprecated message\"".to_string(), None), - ("\"bad arg message\"".to_string(), None), - ("\"level arg message\"".to_string(), Some(LogLevel::Warn)), - ("\"level variable message\"".to_string(), None), + ("\"debug message\"".to_string(), LogLevel::Debug), + ("\"info message\"".to_string(), LogLevel::Info), + ("\"warn message\"".to_string(), LogLevel::Warn), + ("\"error message\"".to_string(), LogLevel::Error), + ("\"exception message\"".to_string(), LogLevel::Error), + ("\"critical message\"".to_string(), LogLevel::Fatal), + ("\"plain message\"".to_string(), LogLevel::Unknown), + ("\"deprecated message\"".to_string(), LogLevel::Unknown), + ("\"bad arg message\"".to_string(), LogLevel::Unknown), + ("\"level arg message\"".to_string(), LogLevel::Warn), + ("\"level variable message\"".to_string(), LogLevel::Unknown), ] ); } @@ -1415,7 +1412,7 @@ fn main() { let log_ref = LogRefBuilder::new().build_from_captures(log_format.captures(line).unwrap(), line); let result = link_to_source(&log_ref, &src_refs).unwrap(); - assert_eq!(result.level, Some(expected), "{line}"); + assert_eq!(result.level, expected, "{line}"); } } diff --git a/src/source_ref.rs b/src/source_ref.rs index 2b767abf..ef9ad462 100644 --- a/src/source_ref.rs +++ b/src/source_ref.rs @@ -20,6 +20,9 @@ pub enum LogLevel { Warn, Error, Fatal, + /// The level could not be determined, which also means the statement might not be logging + /// at all, like a `printf()` or a call that just happens to take a string. + Unknown, } impl LogLevel { @@ -145,8 +148,7 @@ pub struct SourceRef { pub column: usize, pub name: String, pub text: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub level: Option, + pub level: LogLevel, pub quality: usize, #[serde(skip_serializing)] pub(crate) matcher: Regex, @@ -194,7 +196,8 @@ impl SourceRef { .leading_args .iter() .find_map(|arg| LogLevel::from_level_arg(arg)) - .or_else(|| result.call_name.as_deref().and_then(LogLevel::from_name)); + .or_else(|| result.call_name.as_deref().and_then(LogLevel::from_name)) + .unwrap_or(LogLevel::Unknown); if !result.args.is_empty() { args = result.args; }