diff --git a/.gitignore b/.gitignore index 320841a..583e160 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ out +**/out/ dist node_modules .vscode-test/ @@ -13,6 +14,7 @@ obj/ *.log *.tmp *.out.xml +**/*.out.xml # OS generated files .DS_Store diff --git a/.vscodeignore b/.vscodeignore index 0e24bc7..3e44e64 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -5,10 +5,14 @@ src/** .vscodeignore .yarnrc vsc-extension-quickstart.md +package.json.backup +TestData/** +docs/** **/tsconfig.json **/eslint.config.mjs **/*.map **/*.ts +**/*.sh **/.vscode-test.* .github/** @@ -69,6 +73,9 @@ logs npm-debug.log* yarn-debug.log* yarn-error.log* +*.out.xml +**/*.out.xml +TestData/**/out/** # Node modules (should be handled by vsce, but just in case) node_modules/** @@ -116,3 +123,6 @@ VARIABLE-PRINT-GUIDE.md # Packaging scripts package-darwin.sh package-win.sh +package-all.sh +test-debug-output.sh +test-namespace-support.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index e5c213a..bd1634e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to the XSLT Debugger extension will be documented in this file. +## [1.0.0] - 2025 + +### Added + +- Shared XSLT 1.0 instrumentation helper used by both engines so Saxon can now debug XSLT 1.0 stylesheets that do not rely on `msxsl:script`. +- Version-aware Saxon pipeline that switches to the 1.0-safe probes while retaining the existing XSLT 2.0/3.0 instrumentation. +- Integration coverage for the new Saxon 1.0 path (`SaxonEngine_ShouldCaptureVariables_WhenRunningXslt1Stylesheet`) and console smoke tests for both engines. + +### Changed + +- Reorganised integration samples under `TestData/Integration/xslt/compiled/` and `TestData/Integration/xslt/saxon/` to mirror the engine split. +- XsltCompiledEngine now delegates all 1.0 probe insertion to the shared helper, keeping instrumentation logic in one place. +- Bumped the extension version to `1.0.0` and updated packaging docs to reference the new VSIX build numbers. +- `.gitignore` / `.vscodeignore` now filter generated `out/` folders and `*.out.xml` artifacts across the tree. + +### Fixed + +- Ensured Saxon 1.0 runs produce the same breakpoint and variable capture behaviour as the compiled engine by reusing the same probe shapes. + ## [0.6.0] - 2025 ### Added diff --git a/README.md b/README.md index cf6c3c4..40c2fbc 100644 --- a/README.md +++ b/README.md @@ -8,25 +8,25 @@ A powerful Visual Studio Code extension that enables debugging support for XSLT - **Variable Inspection**: Automatically materialises XSLT variables and context nodes inside VSΒ Code’s VARIABLES pane - **XPath Evaluation**: Evaluate XPath expressions in the current context - **Inline C# Scripting**: Debug XSLT stylesheets with embedded C# code using Roslyn -- **Multiple Engines**: Support for compiled XSLT engine (XSLT 1.0) and Saxon engine (XSLT 2.0/3.0) +- **Multiple Engines**: Support for compiled XSLT engine (XSLT 1.0 + inline C#) and Saxon engine (XSLT 1.0 without `msxsl:script`, plus XSLT 2.0/3.0) - **Cross-Platform**: Works on Windows, macOS, and Linux - **Probe Tagging**: Instrumented breakpoints and trace messages are tagged with `dbg:probe="1"` so repeated runs stay idempotent ## XSLT Processing Engines -| Feature | Compiled Engine | Saxon .NET Engine | -| ----------------- | -------------------------------- | ------------------------------------ | -| **XSLT Version** | 1.0 | 2.0, 3.0 | -| **XPath Version** | 1.0 | 2.0, 3.0 | -| **Special Features** | Inline C# via `msxsl:script` | Full XSLT 2.0/3.0 features | -| **Best For** | XSLT 1.0 with inline C# | Modern XSLT 2.0/3.0 stylesheets | +| Feature | Compiled Engine | Saxon .NET Engine | +| ----------------- | -------------------------------- | ---------------------------------------------------- | +| **XSLT Version** | 1.0 | 1.0 (no inline C#), 2.0, 3.0 | +| **XPath Version** | 1.0 | 2.0, 3.0 | +| **Special Features** | Inline C# via `msxsl:script` | Full XSLT 2.0/3.0 features, version-aware probes | +| **Best For** | XSLT 1.0 with inline C# | XSLT 1.0 without `msxsl:script`, plus 2.0/3.0 stylesheets | -**Engine Selection**: Auto-detected based on XSLT version, or manually set with `"engine": "compiled"` or `"engine": "saxonnet"` in launch.json +**Engine Selection**: Auto-detected based on XSLT version and inline script usage, or manually set with `"engine": "compiled"` or `"engine": "saxonnet"` in launch.json ### ⚠️ Current Limitations - Debugging focuses on basic XSLT structures (templates, loops, expressions); complex dynamic calls are not instrumented -- Cannot step into inline C# scripts +- Cannot step into inline C# scripts, though Roslyn instrumentation still logs entry parameters and return values to the console for visibility - Variable inspection uses "falldown" approach: variables are auto-captured via instrumentation as execution progresses forward (cannot re-run or step back to previous lines) - No support for: **step back**, goto targets, set variable, conditional breakpoints, or debug console autocomplete - Variable capture limited to `@select`-based variables; complex variables with content children may not be fully captured @@ -49,10 +49,10 @@ A powerful Visual Studio Code extension that enables debugging support for XSLT ```bash # macOS - code --install-extension xsltdebugger-darwin-darwin-arm64-0.6.0.vsix + code --install-extension xsltdebugger-darwin-darwin-arm64-1.0.0.vsix # Windows - code --install-extension xsltdebugger-windows-win32-x64-0.6.0.vsix + code --install-extension xsltdebugger-windows-win32-x64-1.0.0.vsix ``` 2. **Create a debug configuration** in [.vscode/launch.json](#setting-up-a-debug-configuration) @@ -81,7 +81,7 @@ A powerful Visual Studio Code extension that enables debugging support for XSLT ```bash ./package-all.sh - code --install-extension xsltdebugger-darwin-darwin-arm64-0.6.0.vsix + code --install-extension xsltdebugger-darwin-darwin-arm64-1.0.0.vsix ``` **Platform-specific packaging** (build individually): @@ -89,11 +89,11 @@ A powerful Visual Studio Code extension that enables debugging support for XSLT ```bash # For macOS only ./package-darwin.sh - code --install-extension xsltdebugger-darwin-darwin-arm64-0.6.0.vsix + code --install-extension xsltdebugger-darwin-darwin-arm64-1.0.0.vsix # For Windows only ./package-win.sh - code --install-extension xsltdebugger-windows-win32-x64-0.6.0.vsix + code --install-extension xsltdebugger-windows-win32-x64-1.0.0.vsix ``` ## Usage @@ -202,7 +202,8 @@ Create a `.vscode/launch.json` file in your project workspace: - **Falldown Approach**: Variables appear in the Variables panel as execution flows forward past their declarations (not available before declaration) - **No Step Back**: Since variables are captured via forward instrumentation, you cannot step backward to re-inspect previous values - **XSLT 2.0/3.0**: Full support via Saxon engine with `@select`-based variable capture -- **XSLT 1.0**: Limited variable inspection via Compiled engine +- **XSLT 1.0 (no inline C#)**: Saxon engine reuses 1.0-safe probes (value-of/message) for equivalent capture behaviour +- **XSLT 1.0 + inline C#**: Limited variable inspection via Compiled engine only ### Log Levels @@ -254,7 +255,7 @@ The debugger supports XSLT stylesheets with embedded C# code using `msxsl:script - **Extension** ([src/extension.ts](src/extension.ts)): Registers debug type, resolves paths - **Adapter** ([XsltDebugger.DebugAdapter/](XsltDebugger.DebugAdapter/)): DAP server, engine management, breakpoint/stepping logic -- **Engines**: `XsltCompiledEngine` (XSLT 1.0 + C#), `SaxonEngine` (XSLT 2.0/3.0) +- **Engines**: `XsltCompiledEngine` (XSLT 1.0 + C#), `SaxonEngine` (XSLT 1.0 without `msxsl:script`, plus 2.0/3.0) - **Instrumentation**: - Breakpoints: Both engines inject `dbg:break()` extension function calls at breakpoint lines - Variables: Saxon engine injects `` elements after variable declarations to auto-capture values diff --git a/TestData/Integration/AdvanceFile.xml b/TestData/Integration/xml/AdvanceFile.xml similarity index 100% rename from TestData/Integration/AdvanceFile.xml rename to TestData/Integration/xml/AdvanceFile.xml diff --git a/TestData/Integration/ItemsSample.xml b/TestData/Integration/xml/ItemsSample.xml similarity index 100% rename from TestData/Integration/ItemsSample.xml rename to TestData/Integration/xml/ItemsSample.xml diff --git a/TestData/Integration/xml/ShipmentConf-lml.xml b/TestData/Integration/xml/ShipmentConf-lml.xml new file mode 100644 index 0000000..52325bb --- /dev/null +++ b/TestData/Integration/xml/ShipmentConf-lml.xml @@ -0,0 +1,19 @@ + + + REF-12345 + 1500.50 + ABC-123 + + ORD-001 + Acme Corporation + 2025-10-31 14:30:00 + + 1 + SHIP + + + 2 + LOAD + + + diff --git a/TestData/Integration/ShipmentConf-proper.xml b/TestData/Integration/xml/ShipmentConf-proper.xml similarity index 100% rename from TestData/Integration/ShipmentConf-proper.xml rename to TestData/Integration/xml/ShipmentConf-proper.xml diff --git a/TestData/Integration/xml/ShipmentConf_ns.xml b/TestData/Integration/xml/ShipmentConf_ns.xml new file mode 100644 index 0000000..e766b90 --- /dev/null +++ b/TestData/Integration/xml/ShipmentConf_ns.xml @@ -0,0 +1,50 @@ + + + TRUCK + Truck + Road + Outbound + REF-REDACTED + 2025-07-15T08:30:00Z + 2025-07-15T10:45:00+02:00 + PLATE-REDACTED + 12500.756 + Approved + + + ORD-XXXX + 2025-07-15T00:00:00 + CUST-XXXX + LogicApps - iPaaS Tool Helper + + 10 + Load truck + LD + + + 2025-07-14T12:00:00 + + + 2025-07-13T09:00:00 + + + 2025-06-15T06:30:00 + + + + + + 20 + Second load + LD2 + + + 2023-07-12 + + + 2025-07-16T00:00:00 + + + + + \ No newline at end of file diff --git a/TestData/Integration/VariableLoggingSampleV1Input.xml b/TestData/Integration/xml/VariableLoggingSampleV1Input.xml similarity index 100% rename from TestData/Integration/VariableLoggingSampleV1Input.xml rename to TestData/Integration/xml/VariableLoggingSampleV1Input.xml diff --git a/TestData/Integration/xml/foreach-test.xml b/TestData/Integration/xml/foreach-test.xml new file mode 100644 index 0000000..b1df97a --- /dev/null +++ b/TestData/Integration/xml/foreach-test.xml @@ -0,0 +1,13 @@ + + + + First + Second + Third + + + Low + High + Medium + + diff --git a/TestData/Integration/message-test.xml b/TestData/Integration/xml/message-test.xml similarity index 100% rename from TestData/Integration/message-test.xml rename to TestData/Integration/xml/message-test.xml diff --git a/TestData/Integration/sample copy.xml b/TestData/Integration/xml/sample copy.xml similarity index 100% rename from TestData/Integration/sample copy.xml rename to TestData/Integration/xml/sample copy.xml diff --git a/TestData/Integration/sample-data.xml b/TestData/Integration/xml/sample-data.xml similarity index 100% rename from TestData/Integration/sample-data.xml rename to TestData/Integration/xml/sample-data.xml diff --git a/TestData/Integration/xml/sample-inline-cs-auto-instrument.xml b/TestData/Integration/xml/sample-inline-cs-auto-instrument.xml new file mode 100644 index 0000000..1b07c33 --- /dev/null +++ b/TestData/Integration/xml/sample-inline-cs-auto-instrument.xml @@ -0,0 +1,4 @@ + + + Auto-instrumentation test data + diff --git a/TestData/Integration/sample-inline-cs-with-usings.xml b/TestData/Integration/xml/sample-inline-cs-with-usings.xml similarity index 100% rename from TestData/Integration/sample-inline-cs-with-usings.xml rename to TestData/Integration/xml/sample-inline-cs-with-usings.xml diff --git a/TestData/Integration/sample.xml b/TestData/Integration/xml/sample.xml similarity index 100% rename from TestData/Integration/sample.xml rename to TestData/Integration/xml/sample.xml diff --git a/TestData/Integration/step-into-test.xml b/TestData/Integration/xml/step-into-test.xml similarity index 100% rename from TestData/Integration/step-into-test.xml rename to TestData/Integration/xml/step-into-test.xml diff --git a/TestData/Integration/xslt/compiled/ShipmentConfv1.xslt b/TestData/Integration/xslt/compiled/ShipmentConfv1.xslt new file mode 100644 index 0000000..c07d106 --- /dev/null +++ b/TestData/Integration/xslt/compiled/ShipmentConfv1.xslt @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + Logic App Tool Shipper + + + + + Hello + + + / + + + + + + + + KG + + + + + + + , + + + + + + + + + + + + + \ No newline at end of file diff --git a/TestData/Integration/VariableLoggingSampleV1.xslt b/TestData/Integration/xslt/compiled/VariableLoggingSampleV1.xslt similarity index 100% rename from TestData/Integration/VariableLoggingSampleV1.xslt rename to TestData/Integration/xslt/compiled/VariableLoggingSampleV1.xslt diff --git a/TestData/Integration/xslt/compiled/foreach-test.xslt b/TestData/Integration/xslt/compiled/foreach-test.xslt new file mode 100644 index 0000000..1e55632 --- /dev/null +++ b/TestData/Integration/xslt/compiled/foreach-test.xslt @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TestData/Integration/xslt/compiled/ns-mapping-sample.xslt b/TestData/Integration/xslt/compiled/ns-mapping-sample.xslt new file mode 100644 index 0000000..f0b78af --- /dev/null +++ b/TestData/Integration/xslt/compiled/ns-mapping-sample.xslt @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TestData/Integration/xslt/compiled/sample-inline-cs-auto-instrument.xslt b/TestData/Integration/xslt/compiled/sample-inline-cs-auto-instrument.xslt new file mode 100644 index 0000000..065ba19 --- /dev/null +++ b/TestData/Integration/xslt/compiled/sample-inline-cs-auto-instrument.xslt @@ -0,0 +1,42 @@ + + + + + + + + + + + Auto-Instrumentation Test + + +

C# Method Auto-Instrumentation Test

+

Add 5 + 3 =

+

Multiply 4 * 7 =

+

Formatted:

+ + +
+ +
diff --git a/TestData/Integration/sample-inline-cs-with-usings.xslt b/TestData/Integration/xslt/compiled/sample-inline-cs-with-usings.xslt similarity index 83% rename from TestData/Integration/sample-inline-cs-with-usings.xslt rename to TestData/Integration/xslt/compiled/sample-inline-cs-with-usings.xslt index e5462fc..cd76ddf 100644 --- a/TestData/Integration/sample-inline-cs-with-usings.xslt +++ b/TestData/Integration/xslt/compiled/sample-inline-cs-with-usings.xslt @@ -10,12 +10,14 @@ using System.Globalization; public class DateFormatter { public string FormatCurrentDate() { - return DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); + LogEntry(); + return LogReturn(DateTime.Now.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)); } public string AddDays(string dateStr, int days) { + LogEntry(new { dateStr, days }); DateTime date = DateTime.Parse(dateStr); - return date.AddDays(days).ToString("yyyy-MM-dd"); + return LogReturn(date.AddDays(days).ToString("yyyy-MM-dd")); } } ]]> @@ -42,4 +44,4 @@ public class DateFormatter { - \ No newline at end of file + diff --git a/TestData/Integration/sample-inline-cs.xslt b/TestData/Integration/xslt/compiled/sample-inline-cs.xslt similarity index 82% rename from TestData/Integration/sample-inline-cs.xslt rename to TestData/Integration/xslt/compiled/sample-inline-cs.xslt index 6d4c754..017de9e 100644 --- a/TestData/Integration/sample-inline-cs.xslt +++ b/TestData/Integration/xslt/compiled/sample-inline-cs.xslt @@ -6,7 +6,7 @@ exclude-result-prefixes="msxsl user"> - + @@ -21,8 +21,8 @@ - + - + \ No newline at end of file diff --git a/TestData/Integration/sample.xslt b/TestData/Integration/xslt/compiled/sample.xslt similarity index 100% rename from TestData/Integration/sample.xslt rename to TestData/Integration/xslt/compiled/sample.xslt diff --git a/TestData/Integration/AdvanceXslt2.xslt b/TestData/Integration/xslt/saxon/AdvanceXslt2.xslt similarity index 100% rename from TestData/Integration/AdvanceXslt2.xslt rename to TestData/Integration/xslt/saxon/AdvanceXslt2.xslt diff --git a/TestData/Integration/AdvanceXslt3.xslt b/TestData/Integration/xslt/saxon/AdvanceXslt3.xslt similarity index 100% rename from TestData/Integration/AdvanceXslt3.xslt rename to TestData/Integration/xslt/saxon/AdvanceXslt3.xslt diff --git a/TestData/Integration/xslt/saxon/LmlBasedXslt.xslt b/TestData/Integration/xslt/saxon/LmlBasedXslt.xslt new file mode 100644 index 0000000..e4e6155 --- /dev/null +++ b/TestData/Integration/xslt/saxon/LmlBasedXslt.xslt @@ -0,0 +1,53 @@ + + + + + + + + + + {../CustomerName} + {../../Reference} + {concat(../Number, '/', Sequence)} + {if (../../Net castable as xs:decimal) then + format-number(xs:decimal(../../Net), + '0.00') else '0.00'} + {ef:toUtcDateTime(/ShipmentConfirmation/Orders/Date)} + {../../LicensePlate} + {OperationCode} + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TestData/Integration/ShipmentConf3.xslt b/TestData/Integration/xslt/saxon/ShipmentConf3.xslt similarity index 100% rename from TestData/Integration/ShipmentConf3.xslt rename to TestData/Integration/xslt/saxon/ShipmentConf3.xslt diff --git a/TestData/Integration/xslt/saxon/ShipmentConfv2_ns.xslt b/TestData/Integration/xslt/saxon/ShipmentConfv2_ns.xslt new file mode 100644 index 0000000..5d97401 --- /dev/null +++ b/TestData/Integration/xslt/saxon/ShipmentConfv2_ns.xslt @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + COMPANY-REDACTED + + + + + + + + / + + + + + + hhh: + + + + + + KG + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TestData/Integration/VariableLoggingSample.xslt b/TestData/Integration/xslt/saxon/VariableLoggingSample.xslt similarity index 100% rename from TestData/Integration/VariableLoggingSample.xslt rename to TestData/Integration/xslt/saxon/VariableLoggingSample.xslt diff --git a/TestData/Integration/xslt/saxon/foreach-test.xslt b/TestData/Integration/xslt/saxon/foreach-test.xslt new file mode 100644 index 0000000..1e55632 --- /dev/null +++ b/TestData/Integration/xslt/saxon/foreach-test.xslt @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TestData/Integration/xslt/saxon/ns-mapping-sample.xslt b/TestData/Integration/xslt/saxon/ns-mapping-sample.xslt new file mode 100644 index 0000000..f0b78af --- /dev/null +++ b/TestData/Integration/xslt/saxon/ns-mapping-sample.xslt @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TestData/Integration/sample-xslt2.xslt b/TestData/Integration/xslt/saxon/sample-xslt2.xslt similarity index 100% rename from TestData/Integration/sample-xslt2.xslt rename to TestData/Integration/xslt/saxon/sample-xslt2.xslt diff --git a/TestData/Integration/sample-xslt3.xslt b/TestData/Integration/xslt/saxon/sample-xslt3.xslt similarity index 100% rename from TestData/Integration/sample-xslt3.xslt rename to TestData/Integration/xslt/saxon/sample-xslt3.xslt diff --git a/TestData/Integration/InvalidFunctionCall.xslt b/TestData/Integration/xslt/tests/InvalidFunctionCall.xslt similarity index 100% rename from TestData/Integration/InvalidFunctionCall.xslt rename to TestData/Integration/xslt/tests/InvalidFunctionCall.xslt diff --git a/TestData/Integration/message-test.xslt b/TestData/Integration/xslt/tests/message-test.xslt similarity index 100% rename from TestData/Integration/message-test.xslt rename to TestData/Integration/xslt/tests/message-test.xslt diff --git a/TestData/Integration/step-into-test-simple.xslt b/TestData/Integration/xslt/tests/step-into-test-simple.xslt similarity index 100% rename from TestData/Integration/step-into-test-simple.xslt rename to TestData/Integration/xslt/tests/step-into-test-simple.xslt diff --git a/TestData/Integration/step-into-test.xslt b/TestData/Integration/xslt/tests/step-into-test.xslt similarity index 100% rename from TestData/Integration/step-into-test.xslt rename to TestData/Integration/xslt/tests/step-into-test.xslt diff --git a/TestData/Integration/test-guardrails.xslt b/TestData/Integration/xslt/tests/test-guardrails.xslt similarity index 100% rename from TestData/Integration/test-guardrails.xslt rename to TestData/Integration/xslt/tests/test-guardrails.xslt diff --git a/XsltDebugger.ConsoleTest/LmlCompilationTest.cs b/XsltDebugger.ConsoleTest/LmlCompilationTest.cs new file mode 100644 index 0000000..e30fed1 --- /dev/null +++ b/XsltDebugger.ConsoleTest/LmlCompilationTest.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Xml; +using Saxon.Api; + +namespace XsltDebugger.ConsoleTest; + +/// +/// Test for LmlBasedXslt.xslt compilation +/// +class LmlCompilationTest +{ + static void Main(string[] args) + { + Console.WriteLine("=== XSLT Compilation Test ===\n"); + + var stylesheetPath = Path.GetFullPath("TestData/Integration/xslt/saxon/LmlBasedXslt.xslt"); + var xmlPath = Path.GetFullPath("TestData/Integration/xml/ShipmentConf-lml.xml"); + + if (!File.Exists(stylesheetPath)) + { + Console.WriteLine($"ERROR: Stylesheet not found: {stylesheetPath}"); + return; + } + + if (!File.Exists(xmlPath)) + { + Console.WriteLine($"ERROR: XML not found: {xmlPath}"); + return; + } + + Console.WriteLine($"πŸ“„ Stylesheet: {Path.GetFileName(stylesheetPath)}"); + Console.WriteLine($"πŸ“„ XML: {Path.GetFileName(xmlPath)}"); + Console.WriteLine("\nAttempting to compile XSLT with Saxon (XSLT 3.0)...\n"); + + // Try with Saxon directly + try + { + var processor = new Processor(); + processor.SetProperty("http://saxon.sf.net/feature/xsltVersion", "3.0"); + + var compiler = processor.NewXsltCompiler(); + compiler.XsltLanguageVersion = "3.0"; + + // Capture compilation errors + var errorList = new List(); + compiler.ErrorList = errorList; + + using var styleStream = File.OpenRead(stylesheetPath); + compiler.BaseUri = new Uri(stylesheetPath); + var executable = compiler.Compile(styleStream); + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("βœ“ Compilation successful!"); + Console.ResetColor(); + + // Try to run the transformation + var transformer = executable.Load(); + + using var xmlStream = File.OpenRead(xmlPath); + var docBuilder = processor.NewDocumentBuilder(); + docBuilder.BaseUri = new Uri(xmlPath); + var inputDoc = docBuilder.Build(xmlStream); + + transformer.InitialContextNode = inputDoc; + + var output = new StringWriter(); + var serializer = processor.NewSerializer(output); + + transformer.Run(serializer); + + Console.WriteLine("\nβœ“ Transformation successful!"); + Console.WriteLine("\nOutput:"); + Console.WriteLine(output.ToString()); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("βœ— Compilation/Transformation FAILED!"); + Console.WriteLine($"\n{ex.GetType().Name}: {ex.Message}"); + + if (ex.InnerException != null) + { + Console.WriteLine($"\nInner Exception: {ex.InnerException.Message}"); + } + + Console.ResetColor(); + + Console.WriteLine("\n=== DIAGNOSIS ==="); + Console.WriteLine("The XSLT has a syntax error on lines 20-22:"); + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(" {if (../../Net castable as xs:decimal) then"); + Console.WriteLine(" format-number(xs:decimal(../../Net),"); + Console.WriteLine(" '0.00') else }"); + Console.ResetColor(); + Console.WriteLine("\nThe 'else' clause is empty! It needs a value."); + Console.WriteLine("\nSuggested fixes:"); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine(" 1) Return empty string:"); + Console.WriteLine(" {if (../../Net castable as xs:decimal) then"); + Console.WriteLine(" format-number(xs:decimal(../../Net), '0.00')"); + Console.WriteLine(" else ''}"); + Console.WriteLine("\n 2) Return default value:"); + Console.WriteLine(" {if (../../Net castable as xs:decimal) then"); + Console.WriteLine(" format-number(xs:decimal(../../Net), '0.00')"); + Console.WriteLine(" else '0.00'}"); + Console.ResetColor(); + } + } +} diff --git a/XsltDebugger.ConsoleTest/NamespaceExtractionTest.cs b/XsltDebugger.ConsoleTest/NamespaceExtractionTest.cs new file mode 100644 index 0000000..7181295 --- /dev/null +++ b/XsltDebugger.ConsoleTest/NamespaceExtractionTest.cs @@ -0,0 +1,174 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace XsltDebugger.ConsoleTest; + +/// +/// Test namespace extraction from various XSLT files +/// +class NamespaceExtractionTest +{ + static void Main(string[] args) + { + Console.WriteLine("=== XSLT Namespace Extraction Test ===\n"); + + var testFiles = new[] + { + "TestData/Integration/xslt/saxon/LmlBasedXslt.xslt", + "TestData/Integration/xslt/saxon/VariableLoggingSample.xslt", + "TestData/Integration/xslt/compiled/VariableLoggingSampleV1.xslt", + "TestData/Integration/xslt/saxon/AdvanceXslt2.xslt", + "TestData/Integration/xslt/saxon/AdvanceXslt3.xslt", + "TestData/Integration/xslt/saxon/ShipmentConf3.xslt" + }; + + foreach (var testFile in testFiles) + { + TestNamespaceExtraction(testFile); + } + + Console.WriteLine("\n=== ALL TESTS COMPLETE ===\n"); + } + + static void TestNamespaceExtraction(string stylesheetPath) + { + var fullPath = Path.GetFullPath(stylesheetPath); + var fileName = Path.GetFileName(stylesheetPath); + + Console.WriteLine($"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + Console.WriteLine($"Testing: {fileName}"); + Console.WriteLine($"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + + if (!File.Exists(fullPath)) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"βœ— File not found: {fullPath}"); + Console.ResetColor(); + Console.WriteLine(); + return; + } + + try + { + var xdoc = XDocument.Load(fullPath); + + if (xdoc.Root == null) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine("βœ— No root element found"); + Console.ResetColor(); + Console.WriteLine(); + return; + } + + // Extract namespaces + var namespaces = ExtractNamespaces(xdoc); + + Console.WriteLine($"Found {namespaces.Count} namespace(s):"); + Console.WriteLine(); + + if (namespaces.Count == 0) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine(" (No custom namespaces - XML uses no namespaces)"); + Console.ResetColor(); + } + else + { + foreach (var ns in namespaces.OrderBy(kvp => kvp.Key)) + { + var prefix = string.IsNullOrEmpty(ns.Key) ? "(default)" : ns.Key; + var prefixColor = string.IsNullOrEmpty(ns.Key) ? ConsoleColor.Yellow : ConsoleColor.Cyan; + + Console.ForegroundColor = prefixColor; + Console.Write($" {prefix,-15}"); + Console.ResetColor(); + Console.Write(" β†’ "); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine(ns.Value); + Console.ResetColor(); + } + } + + Console.WriteLine(); + + // Show sample XPath expressions + if (namespaces.Count > 0) + { + Console.WriteLine("Sample watch expressions:"); + var sampleNamespaces = namespaces + .Where(kvp => !string.IsNullOrEmpty(kvp.Key)) + .Take(2); + + foreach (var ns in sampleNamespaces) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" /{ns.Key}:Element/SubElement"); + Console.ResetColor(); + } + + // If there's a default namespace + if (namespaces.ContainsKey("default")) + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine($" /default:Element/SubElement"); + Console.ResetColor(); + } + } + + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine("\nβœ“ Extraction successful"); + Console.ResetColor(); + } + catch (Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"βœ— Error: {ex.Message}"); + Console.ResetColor(); + } + + Console.WriteLine(); + } + + static Dictionary ExtractNamespaces(XDocument xdoc) + { + if (xdoc.Root == null) + { + return new Dictionary(); + } + + const string DebugNamespace = "urn:xslt-debugger"; + var namespaces = new Dictionary(); + + // Extract all namespace declarations from the root element + foreach (var attr in xdoc.Root.Attributes()) + { + if (attr.IsNamespaceDeclaration) + { + var prefix = attr.Name.LocalName == "xmlns" ? string.Empty : attr.Name.LocalName; + var uri = attr.Value; + + // Skip XSLT namespace and debug namespace + if (uri != "http://www.w3.org/1999/XSL/Transform" && + uri != DebugNamespace) + { + // For default namespace (no prefix), also register with "default" prefix + if (string.IsNullOrEmpty(prefix)) + { + namespaces[prefix] = uri; + namespaces["default"] = uri; + } + else + { + namespaces[prefix] = uri; + } + } + } + } + + return namespaces; + } +} diff --git a/XsltDebugger.ConsoleTest/ProgramUsingEngineType.cs b/XsltDebugger.ConsoleTest/ProgramUsingEngineType.cs index 2f47fa2..e43f422 100644 --- a/XsltDebugger.ConsoleTest/ProgramUsingEngineType.cs +++ b/XsltDebugger.ConsoleTest/ProgramUsingEngineType.cs @@ -35,9 +35,9 @@ static async Task Main(string[] args) // Use different defaults based on engine type var defaultXslt = engineType == "compiled" - ? Path.Combine(DefaultTestDataFolder, "sample-inline-cs-with-usings.xslt") - : Path.Combine(DefaultTestDataFolder, "ShipmentConf3.xslt"); - var defaultXml = Path.Combine(DefaultTestDataFolder, engineType == "compiled" ? "sample-inline-cs-with-usings.xml" : "ShipmentConf-proper.xml"); + ? Path.Combine(DefaultTestDataFolder, "xslt/compiled/sample-inline-cs-with-usings.xslt") + : Path.Combine(DefaultTestDataFolder, "xslt/saxon/ShipmentConf3.xslt"); + var defaultXml = Path.Combine(DefaultTestDataFolder, engineType == "compiled" ? "xml/sample-inline-cs-with-usings.xml" : "xml/ShipmentConf-proper.xml"); var stylesheetPath = ResolveInput(filteredArgs.ToArray(), 0, defaultXslt); var xmlPath = ResolveInput(filteredArgs.ToArray(), 1, defaultXml); @@ -77,7 +77,8 @@ static async Task Main(string[] args) var fullStylesheetPath = Path.GetFullPath(stylesheetPath); int breakpointLine = stylesheetPath.Contains("message-test") ? 9 : stylesheetPath.Contains("ShipmentConf3") ? 55 : - stylesheetPath.Contains("VariableLoggingSampleV1") ? 8 : 26; + stylesheetPath.Contains("VariableLoggingSampleV1") ? 8 : + stylesheetPath.Contains("step-into-test") ? 12 : 26; engine.SetBreakpoints(new[] { (fullStylesheetPath, breakpointLine) }); Console.WriteLine($" >> Breakpoint set at line {breakpointLine}\n"); @@ -159,6 +160,31 @@ static async Task Main(string[] args) } } + // Display registered stylesheet namespaces + Console.ForegroundColor = ConsoleColor.White; + Console.WriteLine("\n 🌐 REGISTERED NAMESPACES:"); + Console.ResetColor(); + if (XsltEngineManager.StylesheetNamespaces.Count > 0) + { + foreach (var kvp in XsltEngineManager.StylesheetNamespaces) + { + var prefix = string.IsNullOrEmpty(kvp.Key) ? "(no prefix)" : kvp.Key; + Console.ForegroundColor = ConsoleColor.Cyan; + Console.Write($" {prefix}"); + Console.ResetColor(); + Console.Write(" β†’ "); + Console.ForegroundColor = ConsoleColor.Green; + Console.WriteLine(kvp.Value); + Console.ResetColor(); + } + } + else + { + Console.ForegroundColor = ConsoleColor.DarkGray; + Console.WriteLine(" (no custom namespaces)"); + Console.ResetColor(); + } + // Display XSLT variables Console.ForegroundColor = ConsoleColor.White; Console.WriteLine("\n πŸ“Š XSLT VARIABLES:"); diff --git a/XsltDebugger.ConsoleTest/StepIntoTest.cs b/XsltDebugger.ConsoleTest/StepIntoTest.cs index a4b2c55..4a99416 100644 --- a/XsltDebugger.ConsoleTest/StepIntoTest.cs +++ b/XsltDebugger.ConsoleTest/StepIntoTest.cs @@ -18,8 +18,8 @@ static async Task Main(string[] args) Console.WriteLine("=== XSLT Step-Into Test ===\n"); // Use the step-into test files - var stylesheetPath = Path.GetFullPath("TestData/Integration/step-into-test.xslt"); - var xmlPath = Path.GetFullPath("TestData/Integration/step-into-test.xml"); + var stylesheetPath = Path.GetFullPath("TestData/Integration/xslt/tests/step-into-test.xslt"); + var xmlPath = Path.GetFullPath("TestData/Integration/xml/step-into-test.xml"); if (!File.Exists(stylesheetPath)) { diff --git a/XsltDebugger.ConsoleTest/XsltDebugger.ConsoleTest.csproj b/XsltDebugger.ConsoleTest/XsltDebugger.ConsoleTest.csproj index e891e63..12c6258 100644 --- a/XsltDebugger.ConsoleTest/XsltDebugger.ConsoleTest.csproj +++ b/XsltDebugger.ConsoleTest/XsltDebugger.ConsoleTest.csproj @@ -20,12 +20,16 @@ - + + + - + + + diff --git a/XsltDebugger.DebugAdapter/DapServer.cs b/XsltDebugger.DebugAdapter/DapServer.cs index e6169b0..7d4451b 100644 --- a/XsltDebugger.DebugAdapter/DapServer.cs +++ b/XsltDebugger.DebugAdapter/DapServer.cs @@ -728,11 +728,29 @@ private object EvaluateXPath(XPathNavigator navigator, string expression) private static XmlNamespaceManager CreateNamespaceManager(XPathNavigator navigator) { var manager = new XmlNamespaceManager(navigator.NameTable); + + // Add namespaces from the current XML context foreach (var kvp in navigator.GetNamespacesInScope(XmlNamespaceScope.All)) { var prefix = string.IsNullOrEmpty(kvp.Key) ? string.Empty : kvp.Key; manager.AddNamespace(prefix, kvp.Value); } + + // Add namespaces from the XSLT stylesheet + // These take precedence for XPath evaluation in watch expressions + foreach (var kvp in XsltEngineManager.StylesheetNamespaces) + { + try + { + // AddNamespace will update if prefix already exists + manager.AddNamespace(kvp.Key, kvp.Value); + } + catch + { + // Ignore errors from duplicate/invalid namespace registrations + } + } + return manager; } diff --git a/XsltDebugger.DebugAdapter/InlineCSharpInstrumenter.cs b/XsltDebugger.DebugAdapter/InlineCSharpInstrumenter.cs new file mode 100644 index 0000000..8e6f623 --- /dev/null +++ b/XsltDebugger.DebugAdapter/InlineCSharpInstrumenter.cs @@ -0,0 +1,139 @@ +using System; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; + +namespace XsltDebugger.DebugAdapter; + +/// +/// Instruments inline C# methods to automatically log entry and return values +/// using InlineXsltLogger. +/// +public class InlineCSharpInstrumenter : CSharpSyntaxRewriter +{ + private int _instrumentedMethodCount = 0; + + public int InstrumentedMethodCount => _instrumentedMethodCount; + + public static string Instrument(string sourceCode) + { + var tree = CSharpSyntaxTree.ParseText(sourceCode); + var root = tree.GetRoot(); + + var instrumenter = new InlineCSharpInstrumenter(); + var instrumentedRoot = instrumenter.Visit(root); + + if (XsltEngineManager.IsLogEnabled && instrumenter.InstrumentedMethodCount > 0) + { + XsltEngineManager.NotifyOutput($"[debug] Instrumented {instrumenter.InstrumentedMethodCount} inline C# method(s)"); + } + + return instrumentedRoot.ToFullString(); + } + + public override SyntaxNode? VisitMethodDeclaration(MethodDeclarationSyntax node) + { + // Skip if not public or if void return type + if (!node.Modifiers.Any(m => m.IsKind(SyntaxKind.PublicKeyword)) || + node.ReturnType is PredefinedTypeSyntax predefined && predefined.Keyword.IsKind(SyntaxKind.VoidKeyword)) + { + return base.VisitMethodDeclaration(node); + } + + // Skip if method body is null (abstract/interface methods) + if (node.Body == null) + { + return base.VisitMethodDeclaration(node); + } + + // Check if already instrumented (contains LogEntry or LogReturn calls) + if (AlreadyInstrumented(node)) + { + return base.VisitMethodDeclaration(node); + } + + _instrumentedMethodCount++; + + // Create LogEntry statement with parameters + var logEntryStatement = CreateLogEntryStatement(node); + + // Instrument all return statements with LogReturn + var instrumentedBody = (BlockSyntax)new ReturnStatementInstrumenter().Visit(node.Body); + + // Add LogEntry as first statement in method body + var newStatements = instrumentedBody.Statements.Insert(0, logEntryStatement); + var newBody = instrumentedBody.WithStatements(newStatements); + + return node.WithBody(newBody); + } + + private bool AlreadyInstrumented(MethodDeclarationSyntax node) + { + if (node.Body == null) return false; + + // Check if method contains LogEntry or LogReturn calls + var hasLogCalls = node.Body.DescendantNodes() + .OfType() + .Any(invocation => + { + var identifierName = invocation.Expression as IdentifierNameSyntax; + return identifierName?.Identifier.Text is "LogEntry" or "LogReturn"; + }); + + return hasLogCalls; + } + + private StatementSyntax CreateLogEntryStatement(MethodDeclarationSyntax method) + { + var parameters = method.ParameterList.Parameters; + + if (parameters.Count == 0) + { + // LogEntry(); + return SyntaxFactory.ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.IdentifierName("LogEntry"))); + } + else + { + // LogEntry(new { param1, param2, ... }); + var anonymousObjectProperties = parameters.Select(p => + SyntaxFactory.AnonymousObjectMemberDeclarator( + SyntaxFactory.IdentifierName(p.Identifier.Text))); + + var anonymousObject = SyntaxFactory.AnonymousObjectCreationExpression( + SyntaxFactory.SeparatedList(anonymousObjectProperties)); + + return SyntaxFactory.ExpressionStatement( + SyntaxFactory.InvocationExpression( + SyntaxFactory.IdentifierName("LogEntry"), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(anonymousObject))))); + } + } + + /// + /// Rewrites return statements to wrap the return value with LogReturn() + /// + private class ReturnStatementInstrumenter : CSharpSyntaxRewriter + { + public override SyntaxNode? VisitReturnStatement(ReturnStatementSyntax node) + { + if (node.Expression == null) + { + return base.VisitReturnStatement(node); + } + + // Wrap: return expr; => return LogReturn(expr); + var logReturnInvocation = SyntaxFactory.InvocationExpression( + SyntaxFactory.IdentifierName("LogReturn"), + SyntaxFactory.ArgumentList( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(node.Expression)))); + + return node.WithExpression(logReturnInvocation); + } + } +} diff --git a/XsltDebugger.DebugAdapter/InlineXsltLogger.cs b/XsltDebugger.DebugAdapter/InlineXsltLogger.cs new file mode 100644 index 0000000..ce6bfcc --- /dev/null +++ b/XsltDebugger.DebugAdapter/InlineXsltLogger.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections; +using System.Linq; + +namespace XsltDebugger.DebugAdapter; + +public static class InlineXsltLogger +{ + public static void Log(string message) + { + if (!string.IsNullOrWhiteSpace(message)) + { + XsltEngineManager.NotifyOutput($"[inline] {message}"); + } + } + + public static void Log(string format, params object[] args) + { + try + { + Log(string.Format(format, args)); + } + catch (FormatException) + { + Log(format); + } + } + + public static void LogEntry(object? parameters = null, + [System.Runtime.CompilerServices.CallerMemberName] string member = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0) + { + var xsltLine = GetXsltCallerLine(); + var prefix = xsltLine.HasValue ? $"[{member}:L{lineNumber}@XSLT:{xsltLine.Value}]" : $"[{member}:L{lineNumber}]"; + + if (parameters == null) + { + Log($"{prefix} entered."); + } + else + { + Log($"{prefix} args = {FormatObject(parameters)}"); + } + } + + public static T LogReturn(T value, + [System.Runtime.CompilerServices.CallerMemberName] string member = "", + [System.Runtime.CompilerServices.CallerLineNumber] int lineNumber = 0) + { + var xsltLine = GetXsltCallerLine(); + var prefix = xsltLine.HasValue ? $"[{member}:L{lineNumber}@XSLT:{xsltLine.Value}]" : $"[{member}:L{lineNumber}]"; + Log($"{prefix} return = {FormatObject(value)}"); + return value; + } + + private static int? GetXsltCallerLine() + { + return XsltEngineManager.LastStop?.line; + } + + private static string FormatObject(object? value) + { + if (value == null) + { + return "null"; + } + + return value switch + { + string s => s, + System.Collections.IEnumerable enumerable and not string => "[" + string.Join(", ", enumerable.Cast().Select(FormatObject)) + "]", + _ => value.ToString() ?? value.GetType().Name + }; + } +} diff --git a/XsltDebugger.DebugAdapter/SaxonEngine.cs b/XsltDebugger.DebugAdapter/SaxonEngine.cs index 2799034..1c9817f 100644 --- a/XsltDebugger.DebugAdapter/SaxonEngine.cs +++ b/XsltDebugger.DebugAdapter/SaxonEngine.cs @@ -131,6 +131,9 @@ await Task.Run(() => return; } + // Extract and register stylesheet namespaces for XPath evaluation in watch expressions + ExtractAndRegisterNamespaces(xdoc); + // Instrument the stylesheet for debugging var version = XsltCompiledEngine.GetXsltVersion(xdoc.Root); if (XsltEngineManager.IsLogEnabled) @@ -138,6 +141,8 @@ await Task.Run(() => XsltEngineManager.NotifyOutput($"XSLT version detected: {version}"); } + var useXslt1Instrumentation = version < 2.0m; + // Enable debugging instrumentation only if debugging is enabled if (XsltEngineManager.DebugEnabled) { @@ -150,11 +155,23 @@ await Task.Run(() => _processor.RegisterExtensionFunction(debugExtension); EnsureDebugNamespace(xdoc); - InstrumentStylesheet(xdoc); - InstrumentVariables(xdoc); - if (XsltEngineManager.IsLogEnabled) + if (useXslt1Instrumentation) + { + Xslt1Instrumentation.InstrumentStylesheet(xdoc, _currentStylesheet, DebugNamespace, addProbeAttribute: true); + Xslt1Instrumentation.InstrumentVariables(xdoc, DebugNamespace, addProbeAttribute: true); + if (XsltEngineManager.IsLogEnabled) + { + XsltEngineManager.NotifyOutput("Debugging enabled for XSLT 1.0 (Saxon instrumentation)."); + } + } + else { - XsltEngineManager.NotifyOutput("Debugging enabled for XSLT 2.0/3.0."); + InstrumentStylesheet(xdoc); + InstrumentVariables(xdoc); + if (XsltEngineManager.IsLogEnabled) + { + XsltEngineManager.NotifyOutput("Debugging enabled for XSLT 2.0/3.0."); + } } } else @@ -487,21 +504,22 @@ private bool IsBreakpointHit(string file, int line) xmlDoc.LoadXml(xmlString); // Create navigator from the .NET XmlDocument - var navigator = xmlDoc.CreateNavigator(); + var navigator = xmlDoc.CreateNavigator() ?? throw new InvalidOperationException("Failed to create XPath navigator for serialized Saxon context."); // Now navigate to the equivalent position of the original context node // We'll use the XPath to the original node to position the navigator correctly var pathToContext = GetXPathToNode(context); if (!string.IsNullOrEmpty(pathToContext) && pathToContext != "/") { - var contextNavigator = navigator.SelectSingleNode(pathToContext); + var nsManager = BuildNamespaceManager(xmlDoc); + var contextNavigator = navigator.SelectSingleNode(pathToContext, nsManager); if (contextNavigator != null) { if (XsltEngineManager.TraceEnabled) { XsltEngineManager.NotifyOutput($"[trace] ConvertSaxonNodeToNavigator: positioned at {pathToContext}"); } - return contextNavigator; + return contextNavigator.Clone(); } } @@ -518,6 +536,35 @@ private bool IsBreakpointHit(string file, int line) } } + private static XmlNamespaceManager BuildNamespaceManager(XmlDocument xmlDoc) + { + var manager = new XmlNamespaceManager(xmlDoc.NameTable); + if (xmlDoc.DocumentElement == null) + { + return manager; + } + + try + { + var navigator = xmlDoc.CreateNavigator(); + if (navigator != null && navigator.MoveToFirstChild()) + { + foreach (var kvp in navigator.GetNamespacesInScope(XmlNamespaceScope.All)) + { + var prefix = kvp.Key ?? string.Empty; + try { manager.AddNamespace(prefix, kvp.Value); } + catch { /* ignore duplicates */ } + } + } + } + catch + { + // Ignore namespace extraction issues; fall back to empty manager + } + + return manager; + } + private string GetXPathToNode(XdmNode node) { // Build the XPath to this node from the root @@ -526,27 +573,31 @@ private string GetXPathToNode(XdmNode node) while (current != null && current.NodeKind != XmlNodeType.Document) { - if (current.NodeKind == XmlNodeType.Element) + switch (current.NodeKind) { - var name = current.NodeName?.LocalName ?? ""; - // Count preceding siblings with the same name for position predicate - var position = 1; - var sibling = current; - - // Move to parent, then iterate children to find position - var parent = current.Parent as XdmNode; - if (parent != null) + case XmlNodeType.Element: { - var children = parent.Children().ToList(); - var index = 0; - foreach (var child in children) + var nodeName = current.NodeName; + var localName = nodeName?.LocalName ?? string.Empty; + var prefix = nodeName?.Prefix ?? string.Empty; + var qualifiedName = string.IsNullOrEmpty(prefix) ? localName : $"{prefix}:{localName}"; + + var position = 1; + var parent = current.Parent as XdmNode; + if (parent != null) { - if (child is XdmNode childNode && childNode.NodeKind == XmlNodeType.Element) + var siblings = parent.Children().OfType() + .Where(child => child.NodeKind == XmlNodeType.Element) + .ToList(); + + var index = 0; + foreach (var sibling in siblings) { - if (childNode.NodeName?.LocalName == name) + var siblingName = sibling.NodeName; + if (nodeName != null && nodeName.Equals(siblingName)) { index++; - if (childNode.Implementation == current.Implementation) + if (sibling.Implementation == current.Implementation) { position = index; break; @@ -554,9 +605,27 @@ private string GetXPathToNode(XdmNode node) } } } - } - pathParts.Insert(0, $"{name}[{position}]"); + pathParts.Insert(0, $"{qualifiedName}[{position}]"); + break; + } + case XmlNodeType.Attribute: + { + var nodeName = current.NodeName; + var localName = nodeName?.LocalName ?? string.Empty; + var prefix = nodeName?.Prefix ?? string.Empty; + var qualifiedName = string.IsNullOrEmpty(prefix) ? localName : $"{prefix}:{localName}"; + pathParts.Insert(0, $"@{qualifiedName}"); + break; + } + case XmlNodeType.Text: + { + pathParts.Insert(0, "text()"); + break; + } + default: + pathParts.Insert(0, current.NodeKind.ToString()); + break; } current = current.Parent as XdmNode; @@ -667,6 +736,45 @@ private static string NormalizePath(string path) return result; } + private static void ExtractAndRegisterNamespaces(XDocument xdoc) + { + if (xdoc.Root == null) + { + return; + } + + var namespaces = new Dictionary(); + + // Extract all namespace declarations from the root element + foreach (var attr in xdoc.Root.Attributes()) + { + if (attr.IsNamespaceDeclaration) + { + var prefix = attr.Name.LocalName == "xmlns" ? string.Empty : attr.Name.LocalName; + var uri = attr.Value; + + // Skip XSLT namespace and debug namespace + if (uri != "http://www.w3.org/1999/XSL/Transform" && + uri != DebugNamespace) + { + // For default namespace (no prefix), also register with "default" prefix + // This allows XPath expressions to use "default:ElementName" syntax + if (string.IsNullOrEmpty(prefix)) + { + namespaces[prefix] = uri; + namespaces["default"] = uri; + } + else + { + namespaces[prefix] = uri; + } + } + } + } + + XsltEngineManager.RegisterStylesheetNamespaces(namespaces); + } + private void EnsureDebugNamespace(XDocument doc) { if (doc.Root == null) @@ -772,6 +880,13 @@ private void InstrumentStylesheet(XDocument doc) var isXsltElement = element.Name.Namespace == xsltNamespace; var lineNumber = line!.Value; + if (isXsltElement && + string.Equals(element.Name.LocalName, "template", StringComparison.OrdinalIgnoreCase) && + element.IsEmpty) + { + continue; + } + if (parentIsXslt && string.Equals(parent.Name.LocalName, "choose", StringComparison.OrdinalIgnoreCase)) { if (!(isXsltElement && diff --git a/XsltDebugger.DebugAdapter/Xslt1Instrumentation.cs b/XsltDebugger.DebugAdapter/Xslt1Instrumentation.cs new file mode 100644 index 0000000..1d99849 --- /dev/null +++ b/XsltDebugger.DebugAdapter/Xslt1Instrumentation.cs @@ -0,0 +1,435 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml; +using System.Xml.Linq; + +namespace XsltDebugger.DebugAdapter; + +internal static class Xslt1Instrumentation +{ + public static void InstrumentStylesheet(XDocument doc, string stylesheetPath, XNamespace debugNamespace, bool addProbeAttribute) + { + if (doc.Root == null) + { + return; + } + + var xsltNamespace = doc.Root.Name.Namespace; + var candidates = doc + .Descendants() + .Where(e => ShouldInstrument(e, xsltNamespace)) + .Select(e => (Element: e, Line: GetLineNumber(e))) + .Where(tuple => tuple.Line.HasValue) + .ToList(); + + if (XsltEngineManager.TraceEnabled) + { + try + { + var linesText = string.Join(",", candidates.Select(c => c.Line!.Value).Distinct().OrderBy(x => x)); + XsltEngineManager.NotifyOutput($"[trace] instrumented lines (xslt1) for '{stylesheetPath}': [{linesText}]"); + } + catch + { + // Ignore logging issues + } + } + + foreach (var (element, line) in candidates) + { + if (element.Parent == null) + { + continue; + } + + var isXsltElement = element.Name.Namespace == xsltNamespace; + var localName = element.Name.LocalName; + + var isForEach = isXsltElement && string.Equals(localName, "for-each", StringComparison.OrdinalIgnoreCase); + var isNamedTemplate = isXsltElement && + string.Equals(localName, "template", StringComparison.OrdinalIgnoreCase) && + element.Attribute("name") != null; + + var breakSelect = isNamedTemplate + ? $"dbg:break({line!.Value}, ., 'template-entry')" + : $"dbg:break({line!.Value}, .)"; + var breakCall = new XElement(xsltNamespace + "value-of", + new XAttribute("select", breakSelect)); + + if (addProbeAttribute) + { + breakCall.SetAttributeValue(debugNamespace + "probe", "1"); + } + + XElement? forEachMessage = null; + if (isForEach) + { + var selectAttr = element.Attribute("select")?.Value ?? "(none)"; + forEachMessage = new XElement(xsltNamespace + "message", + new XElement(xsltNamespace + "text", $"[DBG] for-each line={line!.Value} select={selectAttr} pos="), + new XElement(xsltNamespace + "value-of", new XAttribute("select", "position()"))); + + if (addProbeAttribute) + { + forEachMessage.SetAttributeValue(debugNamespace + "probe", "1"); + } + } + + var parent = element.Parent; + var parentIsXslt = parent?.Name.Namespace == xsltNamespace; + + if (parentIsXslt && string.Equals(parent!.Name.LocalName, "choose", StringComparison.OrdinalIgnoreCase)) + { + if (isXsltElement && + (string.Equals(element.Name.LocalName, "when", StringComparison.OrdinalIgnoreCase) || + string.Equals(element.Name.LocalName, "otherwise", StringComparison.OrdinalIgnoreCase))) + { + element.AddFirst(breakCall); + } + continue; + } + + if (isForEach) + { + var lastSort = element.Elements() + .Where(e => e.Name.Namespace == xsltNamespace && + string.Equals(e.Name.LocalName, "sort", StringComparison.OrdinalIgnoreCase)) + .LastOrDefault(); + + if (lastSort != null) + { + if (forEachMessage != null) + { + lastSort.AddAfterSelf(forEachMessage); + } + lastSort.AddAfterSelf(breakCall); + } + else + { + if (forEachMessage != null) + { + element.AddFirst(forEachMessage); + } + element.AddFirst(breakCall); + } + } + else if (isNamedTemplate) + { + var lastParamOrVar = element.Elements() + .Where(e => e.Name.Namespace == xsltNamespace && + (e.Name.LocalName == "param" || e.Name.LocalName == "variable")) + .LastOrDefault(); + + if (lastParamOrVar != null) + { + lastParamOrVar.AddAfterSelf(breakCall); + } + else + { + element.AddFirst(breakCall); + } + } + else if (CanInsertAsFirstChild(element, isXsltElement)) + { + element.AddFirst(breakCall); + } + else + { + element.AddBeforeSelf(breakCall); + } + + if (isNamedTemplate) + { + EnsureTemplateExitProbe(element, line!.Value, xsltNamespace, debugNamespace, addProbeAttribute); + } + } + } + + public static void InstrumentVariables(XDocument doc, XNamespace debugNamespace, bool addProbeAttribute) + { + if (doc.Root == null) + { + return; + } + + var xsltNamespace = doc.Root.Name.Namespace; + + var variables = doc + .Descendants() + .Where(e => e.Name.Namespace == xsltNamespace && + (e.Name.LocalName == "variable" || e.Name.LocalName == "param")) + .Where(e => e.Attribute("name") != null) + .Where(e => !IsTopLevelDeclaration(e, xsltNamespace)) + .ToList(); + + if (XsltEngineManager.IsLogEnabled) + { + XsltEngineManager.NotifyOutput($"[debug] Instrumenting {variables.Count} variable(s) for debugging"); + } + + var groupedByParent = variables.GroupBy(v => v.Parent).ToList(); + + foreach (var group in groupedByParent) + { + var parent = group.Key; + if (parent == null) + { + continue; + } + + var safeVars = new List<(XElement element, string name)>(); + foreach (var variable in group) + { + var varName = variable.Attribute("name")?.Value; + if (string.IsNullOrEmpty(varName)) + { + continue; + } + + if (!IsSafeToInstrumentVariable(variable, xsltNamespace)) + { + if (XsltEngineManager.IsLogEnabled) + { + XsltEngineManager.NotifyOutput($"[debug] Skipped unsafe instrumentation: ${varName}"); + } + continue; + } + + safeVars.Add((variable, varName)); + } + + if (safeVars.Count == 0) + { + continue; + } + + var lastParamOrVar = parent.Elements() + .Where(e => e.Name.Namespace == xsltNamespace && + (e.Name.LocalName == "param" || e.Name.LocalName == "variable")) + .LastOrDefault(); + + if (lastParamOrVar == null) + { + continue; + } + + foreach (var (_, varName) in safeVars) + { + var debugMessage = new XElement( + xsltNamespace + "message", + new XElement(xsltNamespace + "text", $"[DBG] {varName} "), + new XElement(xsltNamespace + "value-of", new XAttribute("select", $"${varName}"))); + + if (addProbeAttribute) + { + debugMessage.SetAttributeValue(debugNamespace + "probe", "1"); + } + + lastParamOrVar.AddAfterSelf(debugMessage); + lastParamOrVar = debugMessage; + + if (XsltEngineManager.IsLogEnabled) + { + XsltEngineManager.NotifyOutput($"[debug] Instrumented variable: ${varName}"); + } + } + } + } + + private static bool ShouldInstrument(XElement element, XNamespace xsltNamespace) + { + if (element.Parent == null) + { + return false; + } + + if (element.Ancestors().Any(a => a.Name.Namespace == xsltNamespace && a.Name.LocalName is "message")) + { + return false; + } + + if (element.Name.Namespace == xsltNamespace) + { + var localName = element.Name.LocalName; + return localName switch + { + "stylesheet" or "transform" => false, + "attribute-set" or "decimal-format" or "import" or "include" or "key" or "namespace-alias" or "output" or "preserve-space" or "strip-space" => false, + "param" or "variable" or "with-param" => false, + "message" => false, + "sort" => false, + _ => true + }; + } + + var nearestXsltAncestor = element.Ancestors().FirstOrDefault(a => a.Name.Namespace == xsltNamespace); + if (nearestXsltAncestor == null) + { + return false; + } + + var ancestorLocal = nearestXsltAncestor.Name.LocalName; + if (ancestorLocal is "stylesheet" or "transform") + { + return false; + } + + return true; + } + + private static int? GetLineNumber(XElement element) + { + if (element is IXmlLineInfo info && info.HasLineInfo()) + { + return info.LineNumber; + } + return null; + } + + private static bool CanInsertAsFirstChild(XElement element, bool isXsltElement) + { + if (element == null || !isXsltElement) + { + return false; + } + + var parent = element.Parent; + var localName = element.Name.LocalName; + + if (XsltCompiledEngine.InlineInstrumentationTargets.Contains(localName)) + { + return true; + } + + if (parent != null) + { + var parentLocal = parent.Name.LocalName; + if (string.Equals(parentLocal, "stylesheet", StringComparison.OrdinalIgnoreCase) || + string.Equals(parentLocal, "transform", StringComparison.OrdinalIgnoreCase) || + string.Equals(parentLocal, "choose", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + if (element.IsEmpty) + { + return false; + } + + if (XsltCompiledEngine.ElementsDisallowingChildInstrumentation.Contains(localName)) + { + return false; + } + + return true; + } + + private static void EnsureTemplateExitProbe( + XElement templateElement, + int lineNumber, + XNamespace xsltNamespace, + XNamespace debugNamespace, + bool addProbeAttribute) + { + var exitSelect = $"dbg:break({lineNumber}, ., 'template-exit')"; + + var existing = templateElement + .Elements() + .FirstOrDefault(e => + e.Name.Namespace == xsltNamespace && + string.Equals(e.Name.LocalName, "value-of", StringComparison.OrdinalIgnoreCase) && + string.Equals(e.Attribute("select")?.Value, exitSelect, StringComparison.Ordinal)); + + if (existing != null) + { + if (addProbeAttribute) + { + existing.SetAttributeValue(debugNamespace + "probe", "1"); + } + return; + } + + var exitCall = new XElement( + xsltNamespace + "value-of", + new XAttribute("select", exitSelect)); + + if (addProbeAttribute) + { + exitCall.SetAttributeValue(debugNamespace + "probe", "1"); + } + + templateElement.Add(exitCall); + } + + private static bool IsSafeToInstrumentVariable(XElement variable, XNamespace xsltNamespace) + { + var parent = variable.Parent; + if (parent == null) + { + return false; + } + + if (HasFragileAncestor(variable, xsltNamespace)) + { + return false; + } + + var parentLocalName = parent.Name.LocalName; + var parentIsXslt = parent.Name.Namespace == xsltNamespace; + + if (parentIsXslt) + { + switch (parentLocalName) + { + case "attribute": + case "comment": + case "processing-instruction": + case "namespace": + case "output": + case "key": + case "decimal-format": + case "character-map": + case "variable": + case "param": + case "with-param": + return false; + } + } + + var attributeAncestor = variable.Ancestors() + .FirstOrDefault(a => a.Name.Namespace == xsltNamespace && + a.Name.LocalName == "attribute"); + if (attributeAncestor != null) + { + return false; + } + + return true; + } + + private static bool IsTopLevelDeclaration(XElement element, XNamespace xsltNamespace) + { + var parent = element.Parent; + if (parent != null && parent.Name.Namespace == xsltNamespace) + { + var parentLocal = parent.Name.LocalName; + if (parentLocal == "stylesheet" || parentLocal == "transform") + { + return true; + } + } + + return false; + } + + private static bool HasFragileAncestor(XElement element, XNamespace xsltNamespace) + { + return element.Ancestors() + .Any(a => a.Name.Namespace == xsltNamespace && + (a.Name.LocalName == "attribute" || + a.Name.LocalName == "comment" || + a.Name.LocalName == "processing-instruction" || + a.Name.LocalName == "namespace")); + } +} diff --git a/XsltDebugger.DebugAdapter/XsltCompiledEngine.cs b/XsltDebugger.DebugAdapter/XsltCompiledEngine.cs index a4159b8..6c4f7fb 100644 --- a/XsltDebugger.DebugAdapter/XsltCompiledEngine.cs +++ b/XsltDebugger.DebugAdapter/XsltCompiledEngine.cs @@ -137,6 +137,9 @@ await Task.Run(() => return; } + // Extract and register stylesheet namespaces for XPath evaluation in watch expressions + ExtractAndRegisterNamespaces(xdoc); + // XSLT 1.0 with XslCompiledTransform XNamespace msxsl = "urn:schemas-microsoft-com:xslt"; var scripts = xdoc.Descendants(msxsl + "script").ToList(); @@ -179,8 +182,18 @@ await Task.Run(() => } EnsureDebugNamespace(xdoc); - InstrumentStylesheet(xdoc); - InstrumentVariables(xdoc); + Xslt1Instrumentation.InstrumentStylesheet(xdoc, _currentStylesheet, DebugNamespace, addProbeAttribute: false); + Xslt1Instrumentation.InstrumentVariables(xdoc, DebugNamespace, addProbeAttribute: false); + + // DEBUG: Save instrumented XSLT for inspection + if (XsltEngineManager.IsTraceEnabled) + { + var debugPath = Path.Combine(Path.GetDirectoryName(_currentStylesheet) ?? ".", "out", + Path.GetFileNameWithoutExtension(_currentStylesheet) + ".instrumented.xslt"); + Directory.CreateDirectory(Path.GetDirectoryName(debugPath) ?? "."); + xdoc.Save(debugPath); + XsltEngineManager.NotifyOutput($"[trace] Saved instrumented XSLT to: {debugPath}"); + } var settings = new XsltSettings(enableDocumentFunction: false, enableScript: false); args.AddExtensionObject(DebugNamespace, new XsltDebugExtension(this, _currentStylesheet)); @@ -331,19 +344,23 @@ internal void RegisterBreakpointHit(string file, int line, XPathNavigator? conte } // Always update the context for evaluation, even when not pausing - XsltEngineManager.UpdateContext(contextNode); + var clonedContext = CloneContextNavigator(contextNode); + XsltEngineManager.UpdateContext(clonedContext); + + // Track current line for inline C# method logging + XsltEngineManager.UpdateCurrentLine(normalized, line); // Check if we hit a user-set breakpoint if (!isTemplateExit && IsBreakpointHit(normalized, line)) { - PauseForBreakpoint(normalized, line, DebugStopReason.Breakpoint, contextNode); + PauseForBreakpoint(normalized, line, DebugStopReason.Breakpoint, clonedContext); return; } // Check if we should stop based on step mode if (ShouldStopForStep(normalized, line, isTemplateExit)) { - PauseForBreakpoint(normalized, line, DebugStopReason.Step, contextNode); + PauseForBreakpoint(normalized, line, DebugStopReason.Step, clonedContext); } } @@ -453,394 +470,203 @@ private static string NormalizePath(string path) return result; } - private void EnsureDebugNamespace(XDocument doc) + private static void ExtractAndRegisterNamespaces(XDocument xdoc) { - if (doc.Root == null) + if (xdoc.Root == null) { return; } - var existing = doc.Root.GetNamespaceOfPrefix("dbg"); - if (existing == null || existing.NamespaceName != DebugNamespace) + var namespaces = new Dictionary(); + + // Extract all namespace declarations from the root element + foreach (var attr in xdoc.Root.Attributes()) { - doc.Root.SetAttributeValue(XNamespace.Xmlns + "dbg", DebugNamespace); + if (attr.IsNamespaceDeclaration) + { + var prefix = attr.Name.LocalName == "xmlns" ? string.Empty : attr.Name.LocalName; + var uri = attr.Value; + + // Skip XSLT namespace and debug namespace + if (uri != "http://www.w3.org/1999/XSL/Transform" && + uri != DebugNamespace) + { + // For default namespace (no prefix), also register with "default" prefix + // This allows XPath expressions to use "default:ElementName" syntax + if (string.IsNullOrEmpty(prefix)) + { + namespaces[prefix] = uri; + namespaces["default"] = uri; + } + else + { + namespaces[prefix] = uri; + } + } + } } + + XsltEngineManager.RegisterStylesheetNamespaces(namespaces); } - private void InstrumentStylesheet(XDocument doc) + private void EnsureDebugNamespace(XDocument doc) { if (doc.Root == null) { return; } - var xsltNamespace = doc.Root.Name.Namespace; - var candidates = doc - .Descendants() - .Where(e => ShouldInstrument(e, xsltNamespace)) - .Select(e => (Element: e, Line: GetLineNumber(e))) - .Where(tuple => tuple.Line.HasValue) - .ToList(); + var existing = doc.Root.GetNamespaceOfPrefix("dbg"); + if (existing == null || existing.NamespaceName != DebugNamespace) + { + doc.Root.SetAttributeValue(XNamespace.Xmlns + "dbg", DebugNamespace); + } + } - if (XsltEngineManager.TraceEnabled) + private static XPathNavigator? CloneContextNavigator(XPathNavigator? context) + { + if (context == null) { - try - { - var linesText = string.Join(",", candidates.Select(c => c.Line!.Value).Distinct().OrderBy(x => x)); - XsltEngineManager.NotifyOutput($"[trace] instrumented lines (compiled) for '{_currentStylesheet}': [{linesText}]"); - } - catch { } + return null; } - foreach (var (element, line) in candidates) + try { - if (element.Parent == null) + var originalClone = context.Clone(); + var pathToContext = GetXPathToNavigator(originalClone); + + // Move to the document root and capture the XML backing the navigator + originalClone.MoveToRoot(); + if (!originalClone.MoveToFirstChild()) { - continue; + return context.Clone(); } - var isXsltElement = element.Name.Namespace == xsltNamespace; - - // Check if this is a named template (for step-into support) - var isNamedTemplate = isXsltElement && - string.Equals(element.Name.LocalName, "template", StringComparison.OrdinalIgnoreCase) && - element.Attribute("name") != null; - - // Create breakpoint call with template-entry marker if it's a named template - var breakCall = isNamedTemplate - ? new XElement(xsltNamespace + "value-of", - new XAttribute("select", $"dbg:break({line!.Value}, ., 'template-entry')")) - : new XElement(xsltNamespace + "value-of", - new XAttribute("select", $"dbg:break({line!.Value}, .)")); - - var parent = element.Parent; - var parentIsXslt = parent?.Name.Namespace == xsltNamespace; - - if (parentIsXslt && string.Equals(parent!.Name.LocalName, "choose", StringComparison.OrdinalIgnoreCase)) + while (originalClone.NodeType != XPathNodeType.Element) { - if (isXsltElement && (string.Equals(element.Name.LocalName, "when", StringComparison.OrdinalIgnoreCase) || - string.Equals(element.Name.LocalName, "otherwise", StringComparison.OrdinalIgnoreCase))) + if (!originalClone.MoveToNext()) { - element.AddFirst(breakCall); + return context.Clone(); } - continue; } - // Special handling for named templates with params/variables - // In XSLT 1.0, ALL params and variables must come first - if (isNamedTemplate) - { - // Find the last param/variable in this template - var lastParamOrVar = element.Elements() - .Where(e => e.Name.Namespace == xsltNamespace && - (e.Name.LocalName == "param" || e.Name.LocalName == "variable")) - .LastOrDefault(); + var xml = originalClone.OuterXml; + var xmlDoc = new XmlDocument(); + xmlDoc.LoadXml(xml); - if (lastParamOrVar != null) - { - // Insert the template-entry breakpoint AFTER all params/variables - lastParamOrVar.AddAfterSelf(breakCall); - } - else - { - // No params or variables, safe to insert as first child - element.AddFirst(breakCall); - } - } - else if (CanInsertAsFirstChild(element, isXsltElement)) + var navigator = xmlDoc.CreateNavigator(); + if (navigator == null) { - element.AddFirst(breakCall); - } - else - { - element.AddBeforeSelf(breakCall); + return context.Clone(); } - if (isNamedTemplate) + if (!string.IsNullOrEmpty(pathToContext) && pathToContext != "/") { - EnsureTemplateExitBreakpoint(element, line!.Value, xsltNamespace); + var nsManager = BuildNamespaceManager(xmlDoc); + var positioned = navigator.SelectSingleNode(pathToContext, nsManager); + if (positioned != null) + { + return positioned.Clone(); + } } - } - } - - private static void EnsureTemplateExitBreakpoint(XElement templateElement, int lineNumber, XNamespace xsltNamespace) - { - var exitSelect = $"dbg:break({lineNumber}, ., 'template-exit')"; - - var existing = templateElement - .Elements() - .FirstOrDefault(e => - e.Name.Namespace == xsltNamespace && - string.Equals(e.Name.LocalName, "value-of", StringComparison.OrdinalIgnoreCase) && - string.Equals(e.Attribute("select")?.Value, exitSelect, StringComparison.Ordinal)); - - if (existing != null) - { - return; - } - - var exitCall = new XElement( - xsltNamespace + "value-of", - new XAttribute("select", exitSelect)); - - templateElement.Add(exitCall); - } - - private static bool ShouldInstrument(XElement element, XNamespace xsltNamespace) - { - if (element.Parent == null) - { - return false; - } - // Exclude any elements inside xsl:message to avoid instrumentation conflicts - if (element.Ancestors().Any(a => a.Name.Namespace == xsltNamespace && a.Name.LocalName is "message")) - { - return false; + return navigator; } - - if (element.Name.Namespace == xsltNamespace) + catch { - var localName = element.Name.LocalName; - return localName switch + try { - "stylesheet" or "transform" => false, - "attribute-set" or "decimal-format" or "import" or "include" or "key" or "namespace-alias" or "output" or "preserve-space" or "strip-space" => false, - "param" or "variable" or "with-param" => false, - // Exclude xsl:message to avoid instrumentation conflicts - "message" => false, - _ => true - }; - } - - var nearestXsltAncestor = element.Ancestors().FirstOrDefault(a => a.Name.Namespace == xsltNamespace); - if (nearestXsltAncestor == null) - { - return false; - } - - var ancestorLocal = nearestXsltAncestor.Name.LocalName; - if (ancestorLocal is "stylesheet" or "transform") - { - return false; - } - - return true; - } - - private static int? GetLineNumber(XElement element) - { - if (element is IXmlLineInfo info && info.HasLineInfo()) - { - return info.LineNumber; + return context.Clone(); + } + catch + { + return null; + } } - return null; } - private static bool CanInsertAsFirstChild(XElement element, bool isXsltElement) + private static XmlNamespaceManager BuildNamespaceManager(XmlDocument xmlDoc) { - if (element == null) - { - return false; - } - - if (!isXsltElement) - { - return false; - } - - var parent = element.Parent; - var localName = element.Name.LocalName; - - if (InlineInstrumentationTargets.Contains(localName)) + var manager = new XmlNamespaceManager(xmlDoc.NameTable); + if (xmlDoc.DocumentElement == null) { - return true; + return manager; } - if (parent != null) + try { - var parentLocal = parent.Name.LocalName; - if (string.Equals(parentLocal, "stylesheet", StringComparison.OrdinalIgnoreCase) || - string.Equals(parentLocal, "transform", StringComparison.OrdinalIgnoreCase) || - string.Equals(parentLocal, "choose", StringComparison.OrdinalIgnoreCase)) + var navigator = xmlDoc.CreateNavigator(); + if (navigator != null && navigator.MoveToFirstChild()) { - return true; + foreach (var kvp in navigator.GetNamespacesInScope(XmlNamespaceScope.All)) + { + var prefix = kvp.Key ?? string.Empty; + try { manager.AddNamespace(prefix, kvp.Value); } + catch { /* ignore duplicates */ } + } } } - - if (element.IsEmpty) - { - return false; - } - - if (ElementsDisallowingChildInstrumentation.Contains(localName)) + catch { - return false; + // Ignore namespace extraction issues; fall back to empty manager } - return true; + return manager; } - private static void InstrumentVariables(XDocument doc) + private static string GetXPathToNavigator(XPathNavigator navigator) { - if (doc.Root == null) - { - return; - } - - var xsltNamespace = doc.Root.Name.Namespace; - - // Find all xsl:variable and xsl:param elements - var variables = doc - .Descendants() - .Where(e => e.Name.Namespace == xsltNamespace && - (e.Name.LocalName == "variable" || e.Name.LocalName == "param")) - .Where(e => e.Attribute("name") != null) - .Where(e => !IsTopLevelDeclaration(e, xsltNamespace)) - .ToList(); - - if (XsltEngineManager.IsLogEnabled) - { - XsltEngineManager.NotifyOutput($"[debug] Instrumenting {variables.Count} variable(s) for debugging"); - } - - // Group variables by parent to handle templates correctly - // In XSLT 1.0, ALL params and variables must come first in a template - var groupedByParent = variables.GroupBy(v => v.Parent).ToList(); - - foreach (var group in groupedByParent) + try { - var parent = group.Key; - if (parent == null) continue; + var pathParts = new List(); + var current = navigator.Clone(); - // Collect safe variables in this group - var safeVars = new List<(XElement element, string name)>(); - foreach (var variable in group) + while (current.NodeType != XPathNodeType.Root) { - var varName = variable.Attribute("name")?.Value; - if (string.IsNullOrEmpty(varName)) + switch (current.NodeType) { - continue; - } - - if (!IsSafeToInstrumentVariable(variable, xsltNamespace)) - { - if (XsltEngineManager.IsLogEnabled) + case XPathNodeType.Element: + { + var position = 1; + var sibling = current.Clone(); + while (sibling.MoveToPrevious()) + { + if (sibling.Name == current.Name) + { + position++; + } + } + + pathParts.Insert(0, $"{current.Name}[{position}]"); + break; + } + case XPathNodeType.Attribute: + { + pathParts.Insert(0, $"@{current.Name}"); + break; + } + case XPathNodeType.Text: { - XsltEngineManager.NotifyOutput($"[debug] Skipped unsafe instrumentation: ${varName}"); + pathParts.Insert(0, "text()"); + break; } - continue; + default: + pathParts.Insert(0, current.NodeType.ToString()); + break; } - safeVars.Add((variable, varName)); - } - - if (safeVars.Count == 0) continue; - - // Find the last param/variable in this parent - // This ensures we insert AFTER all params/variables, not between them - var lastParamOrVar = parent.Elements() - .Where(e => e.Name.Namespace == xsltNamespace && - (e.Name.LocalName == "param" || e.Name.LocalName == "variable")) - .LastOrDefault(); - - if (lastParamOrVar == null) continue; - - // Insert debug messages for each variable, but AFTER the last param/variable - foreach (var (variable, varName) in safeVars) - { - var debugMessage = new XElement( - xsltNamespace + "message", - new XElement(xsltNamespace + "text", $"[DBG] {varName} "), - new XElement(xsltNamespace + "value-of", new XAttribute("select", $"${varName}")) - ); - - // Insert after the last param/variable, not after each one - lastParamOrVar.AddAfterSelf(debugMessage); - // Update lastParamOrVar so next message goes after this one - lastParamOrVar = debugMessage; - - if (XsltEngineManager.IsLogEnabled) + if (!current.MoveToParent()) { - XsltEngineManager.NotifyOutput($"[debug] Instrumented variable: ${varName}"); + break; } } - } - } - - private static bool IsSafeToInstrumentVariable(XElement variable, XNamespace xsltNamespace) - { - var parent = variable.Parent; - if (parent == null) - { - return false; - } - - if (HasFragileAncestor(variable, xsltNamespace)) - { - return false; - } - - var parentLocalName = parent.Name.LocalName; - var parentIsXslt = parent.Name.Namespace == xsltNamespace; - - if (parentIsXslt) - { - switch (parentLocalName) - { - case "attribute": - case "comment": - case "processing-instruction": - case "namespace": - case "output": - case "key": - case "decimal-format": - case "character-map": - case "variable": - case "param": - case "with-param": - return false; - } - } - // Don't instrument variables inside xsl:attribute - var attributeAncestor = variable.Ancestors() - .FirstOrDefault(a => a.Name.Namespace == xsltNamespace && - a.Name.LocalName == "attribute"); - if (attributeAncestor != null) - { - return false; + return pathParts.Count > 0 ? "/" + string.Join("/", pathParts) : "/"; } - - return true; - } - - private static bool IsTopLevelDeclaration(XElement element, XNamespace xsltNamespace) - { - // Check if this is a top-level variable/param (direct child of stylesheet/transform) - var parent = element.Parent; - if (parent != null && parent.Name.Namespace == xsltNamespace) + catch { - var parentLocal = parent.Name.LocalName; - if (parentLocal == "stylesheet" || parentLocal == "transform") - { - return true; - } + return "/"; } - - return false; - } - - private static bool HasFragileAncestor(XElement element, XNamespace xsltNamespace) - { - // For XSLT 1.0, we have fewer fragile contexts than XSLT 2.0/3.0 - // Main concerns: attribute generation, key/sort contexts - return element.Ancestors() - .Any(a => a.Name.Namespace == xsltNamespace && - (a.Name.LocalName == "attribute" || - a.Name.LocalName == "comment" || - a.Name.LocalName == "processing-instruction" || - a.Name.LocalName == "namespace")); } private static List ExtractUsingStatements(string code) @@ -875,7 +701,9 @@ private object CompileAndCreateExtensionObject(string code) "using System.Text;", "using System.Xml;", "using System.Xml.XPath;", - "using System.Xml.Xsl;" + "using System.Xml.Xsl;", + "using XsltDebugger.DebugAdapter;", + "using static XsltDebugger.DebugAdapter.InlineXsltLogger;" }; var existingUsings = ExtractUsingStatements(classCode); @@ -883,14 +711,34 @@ private object CompileAndCreateExtensionObject(string code) requiredUsings.Where(u => !existingUsings.Contains(u, StringComparer.OrdinalIgnoreCase))); var sourceCode = string.Concat(prelude, Environment.NewLine, classCode); + + // Instrument inline C# methods when debugging is enabled + if (XsltEngineManager.DebugEnabled) + { + sourceCode = InlineCSharpInstrumenter.Instrument(sourceCode); + } + var syntaxTree = Microsoft.CodeAnalysis.CSharp.CSharpSyntaxTree.ParseText(sourceCode); var references = new List { MetadataReference.CreateFromFile(typeof(object).Assembly.Location), MetadataReference.CreateFromFile(typeof(XsltArgumentList).Assembly.Location), MetadataReference.CreateFromFile(typeof(System.Xml.XmlDocument).Assembly.Location), - MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location) + MetadataReference.CreateFromFile(typeof(System.Linq.Enumerable).Assembly.Location), + MetadataReference.CreateFromFile(typeof(System.Runtime.GCSettings).Assembly.Location), + MetadataReference.CreateFromFile(typeof(XsltEngineManager).Assembly.Location), + MetadataReference.CreateFromFile(typeof(InlineXsltLogger).Assembly.Location) }; + + var coreLibDirectory = Path.GetDirectoryName(typeof(object).Assembly.Location); + if (!string.IsNullOrEmpty(coreLibDirectory)) + { + var runtimeAssemblyPath = Path.Combine(coreLibDirectory, "System.Runtime.dll"); + if (File.Exists(runtimeAssemblyPath)) + { + references.Add(MetadataReference.CreateFromFile(runtimeAssemblyPath)); + } + } var compilation = Microsoft.CodeAnalysis.CSharp.CSharpCompilation.Create( "InlineXsltExtAssembly", new[] { syntaxTree }, @@ -906,18 +754,22 @@ private object CompileAndCreateExtensionObject(string code) ms.Seek(0, SeekOrigin.Begin); var assembly = Assembly.Load(ms.ToArray()); - Type? chosen = null; - foreach (var t in assembly.GetTypes()) - { - var methods = t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly); - if (methods.Length > 0) - { - chosen = t; - break; - } - } + var allTypes = assembly.GetTypes(); + var usableTypes = allTypes + .Where(t => + !t.IsAbstract && + !t.IsInterface && + !t.IsGenericType && + !t.IsGenericTypeDefinition && + !Attribute.IsDefined(t, typeof(System.Runtime.CompilerServices.CompilerGeneratedAttribute), inherit: false)) + .ToList(); + + Type? chosen = usableTypes + .FirstOrDefault(t => t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).Length > 0) + ?? usableTypes.FirstOrDefault(t => !t.IsNested) + ?? usableTypes.FirstOrDefault() + ?? allTypes.FirstOrDefault(t => !t.IsAbstract && !t.IsInterface); - chosen ??= assembly.GetTypes().FirstOrDefault(t => !t.IsNested); if (chosen == null) { throw new Exception("Compiled assembly does not contain a usable type for XSLT extension."); diff --git a/XsltDebugger.DebugAdapter/XsltEngineManager.cs b/XsltDebugger.DebugAdapter/XsltEngineManager.cs index 4e185f5..268671e 100644 --- a/XsltDebugger.DebugAdapter/XsltEngineManager.cs +++ b/XsltDebugger.DebugAdapter/XsltEngineManager.cs @@ -18,6 +18,9 @@ public static class XsltEngineManager // XSLT Variables storage public static Dictionary Variables { get; private set; } = new(); + // XSLT Stylesheet namespaces (prefix -> URI mapping) + public static Dictionary StylesheetNamespaces { get; private set; } = new(); + public static bool DebugEnabled { get; private set; } = true; public static LogLevel CurrentLogLevel { get; private set; } = LogLevel.Log; @@ -73,6 +76,13 @@ public static void UpdateContext(XPathNavigator? context) } } + public static void UpdateCurrentLine(string file, int line) + { + // Update LastStop to track the current executing line (not just when paused) + // This allows inline C# methods to report the XSLT line that called them + LastStop = (file, line); + } + public static void NotifyOutput(string message) { if (!string.IsNullOrWhiteSpace(message)) @@ -104,6 +114,25 @@ public static void ClearVariables() } } + public static void RegisterStylesheetNamespaces(Dictionary namespaces) + { + StylesheetNamespaces = new Dictionary(namespaces); + if (IsTraceEnabled) + { + var count = namespaces.Count; + NotifyOutput($"[trace] Registered {count} stylesheet namespace(s)"); + } + } + + public static void ClearStylesheetNamespaces() + { + StylesheetNamespaces.Clear(); + if (IsTraceAllEnabled) + { + NotifyOutput("[traceall] Stylesheet namespaces cleared"); + } + } + private static string FormatValue(object? value) { if (value == null) return "null"; @@ -120,6 +149,7 @@ public static void Reset() DebugEnabled = true; CurrentLogLevel = LogLevel.Log; ClearVariables(); + ClearStylesheetNamespaces(); } } diff --git a/XsltDebugger.Tests/CompiledEngineIntegrationTests.cs b/XsltDebugger.Tests/CompiledEngineIntegrationTests.cs index 19184fa..f43ee68 100644 --- a/XsltDebugger.Tests/CompiledEngineIntegrationTests.cs +++ b/XsltDebugger.Tests/CompiledEngineIntegrationTests.cs @@ -27,11 +27,13 @@ private static string GetTestDataPath(string relativePath) [Fact] public async Task CompiledEngine_ShouldTransformInlineScriptSample_WithTraceLogging() { - var stylesheetPath = GetTestDataPath("Integration/sample-inline-cs-with-usings.xslt"); - var xmlPath = GetTestDataPath("Integration/sample-inline-cs-with-usings.xml"); + var stylesheetPath = GetTestDataPath("Integration/xslt/compiled/sample-inline-cs-with-usings.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/sample-inline-cs-with-usings.xml"); var fullStylesheetPath = Path.GetFullPath(stylesheetPath); var fullXmlPath = Path.GetFullPath(xmlPath); - var breakpoints = new[] { (fullStylesheetPath, 24) }; + var lines = File.ReadAllLines(fullStylesheetPath); + var templateLine = Array.FindIndex(lines, line => line.Contains("", StringComparison.Ordinal)) + 1; + var breakpoints = new[] { (fullStylesheetPath, templateLine) }; var engine = new XsltCompiledEngine(); var outputLog = new List(); @@ -74,7 +76,7 @@ void OnStopped(string file, int line, DebugStopReason reason) var breakpointHit = await breakpointHitSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); breakpointHit.file.Should().Be(fullStylesheetPath); - breakpointHit.line.Should().Be(24); + breakpointHit.line.Should().Be(templateLine); breakpointHit.reason.Should().Be(DebugStopReason.Breakpoint); var exitCode = await terminatedSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); @@ -108,8 +110,8 @@ void OnStopped(string file, int line, DebugStopReason reason) [Fact] public async Task CompiledEngine_ShouldCaptureVariablesAndHitBreakpoints() { - var stylesheetPath = GetTestDataPath("Integration/VariableLoggingSampleV1.xslt"); - var xmlPath = GetTestDataPath("Integration/VariableLoggingSampleV1Input.xml"); + var stylesheetPath = GetTestDataPath("Integration/xslt/compiled/VariableLoggingSampleV1.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/VariableLoggingSampleV1Input.xml"); var fullStylesheetPath = Path.GetFullPath(stylesheetPath); var fullXmlPath = Path.GetFullPath(xmlPath); var breakpoints = new[] { (fullStylesheetPath, 8) }; @@ -186,6 +188,401 @@ void OnStopped(string file, int line, DebugStopReason reason) } } + [Fact] + public async Task CompiledEngine_ShouldInstrumentInlineCSharpMethods() + { + var stylesheetPath = GetTestDataPath("Integration/xslt/compiled/sample-inline-cs-auto-instrument.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/sample-inline-cs-auto-instrument.xml"); + var fullStylesheetPath = Path.GetFullPath(stylesheetPath); + var fullXmlPath = Path.GetFullPath(xmlPath); + + var engine = new XsltCompiledEngine(); + var outputLog = new List(); + var outputLock = new object(); + var terminatedSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnOutput(string message) + { + lock (outputLock) + { + outputLog.Add(message); + } + } + + void OnTerminated(int code) => terminatedSource.TrySetResult(code); + + XsltEngineManager.Reset(); + XsltEngineManager.SetDebugFlags(true, LogLevel.Log); + XsltEngineManager.EngineOutput += OnOutput; + XsltEngineManager.EngineTerminated += OnTerminated; + + try + { + await engine.StartAsync(fullStylesheetPath, fullXmlPath, stopOnEntry: false); + + var exitCode = await terminatedSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + exitCode.Should().Be(0, "transformation should complete successfully"); + + List snapshot; + lock (outputLock) + { + snapshot = outputLog.ToList(); + } + + // Verify instrumentation occurred + snapshot.Should().Contain(message => message.Contains("Instrumented 3 inline C# method(s)", StringComparison.OrdinalIgnoreCase), + "should report instrumenting 3 C# methods"); + + // Verify Add method logging + snapshot.Should().Contain(message => message.Contains("[inline] [Add:", StringComparison.Ordinal) && message.Contains("args = { a = 5, b = 3 }"), + "should log Add method entry with parameters"); + snapshot.Should().Contain(message => message.Contains("[inline] [Add:", StringComparison.Ordinal) && message.Contains("return = 8"), + "should log Add method return value"); + + // Verify Multiply method logging + snapshot.Should().Contain(message => message.Contains("[inline] [Multiply:", StringComparison.Ordinal) && message.Contains("args = { a = 4, b = 7 }"), + "should log Multiply method entry with parameters"); + snapshot.Should().Contain(message => message.Contains("[inline] [Multiply:", StringComparison.Ordinal) && message.Contains("return = 28"), + "should log Multiply method return value"); + + // Verify FormatNumber method logging + snapshot.Should().Contain(message => message.Contains("[inline] [FormatNumber:", StringComparison.Ordinal) && message.Contains("args = { num = 1000000 }"), + "should log FormatNumber method entry with parameters"); + snapshot.Should().Contain(message => message.Contains("[inline] [FormatNumber:", StringComparison.Ordinal) && message.Contains("return = 1,000,000"), + "should log FormatNumber method return value"); + } + finally + { + XsltEngineManager.EngineOutput -= OnOutput; + XsltEngineManager.EngineTerminated -= OnTerminated; + XsltEngineManager.Reset(); + } + } + + [Fact] + public async Task CompiledEngine_ShouldIncludeXsltLineNumbersInInlineCSharpLogs() + { + var stylesheetPath = GetTestDataPath("Integration/xslt/compiled/ShipmentConfv1.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/ShipmentConf-proper.xml"); + var fullStylesheetPath = Path.GetFullPath(stylesheetPath); + var fullXmlPath = Path.GetFullPath(xmlPath); + + var engine = new XsltCompiledEngine(); + var outputLog = new List(); + var outputLock = new object(); + var terminatedSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnOutput(string message) + { + lock (outputLock) + { + outputLog.Add(message); + } + } + + void OnTerminated(int code) => terminatedSource.TrySetResult(code); + + XsltEngineManager.Reset(); + XsltEngineManager.SetDebugFlags(true, LogLevel.Log); + XsltEngineManager.EngineOutput += OnOutput; + XsltEngineManager.EngineTerminated += OnTerminated; + + try + { + await engine.StartAsync(fullStylesheetPath, fullXmlPath, stopOnEntry: false); + + var exitCode = await terminatedSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + exitCode.Should().Be(0, "transformation should complete successfully"); + + List snapshot; + lock (outputLock) + { + snapshot = outputLog.ToList(); + } + + // Verify XSLT line numbers are included in inline C# logs + snapshot.Should().Contain(message => message.Contains("@XSLT:75", StringComparison.Ordinal), + "should include XSLT line 75 for RoundToTwoDecimals call"); + snapshot.Should().Contain(message => message.Contains("@XSLT:88", StringComparison.Ordinal), + "should include XSLT line 88 for MinDate call"); + + // Verify C# line numbers are also included + snapshot.Should().Contain(message => message.Contains("[RoundToTwoDecimals:L", StringComparison.Ordinal), + "should include C# line number for RoundToTwoDecimals"); + snapshot.Should().Contain(message => message.Contains("[MinDate:L", StringComparison.Ordinal), + "should include C# line number for MinDate"); + } + finally + { + XsltEngineManager.EngineOutput -= OnOutput; + XsltEngineManager.EngineTerminated -= OnTerminated; + XsltEngineManager.Reset(); + } + } + + [Fact] + public async Task CompiledEngine_ShouldNotDoubleInstrumentManuallyLoggedMethods() + { + var stylesheetPath = GetTestDataPath("Integration/xslt/compiled/sample-inline-cs-with-usings.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/sample-inline-cs-with-usings.xml"); + var fullStylesheetPath = Path.GetFullPath(stylesheetPath); + var fullXmlPath = Path.GetFullPath(xmlPath); + + var engine = new XsltCompiledEngine(); + var outputLog = new List(); + var outputLock = new object(); + var terminatedSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnOutput(string message) + { + lock (outputLock) + { + outputLog.Add(message); + } + } + + void OnTerminated(int code) => terminatedSource.TrySetResult(code); + + XsltEngineManager.Reset(); + XsltEngineManager.SetDebugFlags(true, LogLevel.Log); + XsltEngineManager.EngineOutput += OnOutput; + XsltEngineManager.EngineTerminated += OnTerminated; + + try + { + await engine.StartAsync(fullStylesheetPath, fullXmlPath, stopOnEntry: false); + + var exitCode = await terminatedSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + exitCode.Should().Be(0, "transformation should complete successfully"); + + List snapshot; + lock (outputLock) + { + snapshot = outputLog.ToList(); + } + + // Should NOT see "Instrumented N inline C# method(s)" because methods already use LogEntry/LogReturn + snapshot.Should().NotContain(message => message.Contains("Instrumented 2 inline C# method(s)", StringComparison.OrdinalIgnoreCase), + "should not instrument already manually logged methods"); + + // But should still see the manual logging output (with XSLT line numbers now) + snapshot.Should().Contain(message => message.Contains("[inline] [FormatCurrentDate:", StringComparison.Ordinal), + "should still have manual logging from FormatCurrentDate"); + snapshot.Should().Contain(message => message.Contains("[inline] [AddDays:", StringComparison.Ordinal), + "should still have manual logging from AddDays"); + } + finally + { + XsltEngineManager.EngineOutput -= OnOutput; + XsltEngineManager.EngineTerminated -= OnTerminated; + XsltEngineManager.Reset(); + } + } + + [Fact] + public async Task CompiledEngine_ShouldLogForEachPositionWithoutSort() + { + var stylesheetPath = GetTestDataPath("Integration/xslt/compiled/foreach-test.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/foreach-test.xml"); + var fullStylesheetPath = Path.GetFullPath(stylesheetPath); + var fullXmlPath = Path.GetFullPath(xmlPath); + + var engine = new XsltCompiledEngine(); + var outputLog = new List(); + var outputLock = new object(); + var terminatedSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnOutput(string message) + { + lock (outputLock) + { + outputLog.Add(message); + } + } + + void OnTerminated(int code) => terminatedSource.TrySetResult(code); + + XsltEngineManager.Reset(); + XsltEngineManager.SetDebugFlags(true, LogLevel.Log); + XsltEngineManager.EngineOutput += OnOutput; + XsltEngineManager.EngineTerminated += OnTerminated; + + try + { + await engine.StartAsync(fullStylesheetPath, fullXmlPath, stopOnEntry: false); + + var exitCode = await terminatedSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + exitCode.Should().Be(0, "transformation should complete successfully"); + + List snapshot; + lock (outputLock) + { + snapshot = outputLog.ToList(); + } + + // Verify for-each position logging for simple loop (line 10) + snapshot.Should().Contain(message => message.Contains("[xsl:message] [DBG] for-each line=10", StringComparison.Ordinal) && message.Contains("select=/root/items/item", StringComparison.Ordinal) && message.Contains("pos=1", StringComparison.Ordinal), + "should log position 1 for first iteration"); + snapshot.Should().Contain(message => message.Contains("[xsl:message] [DBG] for-each line=10", StringComparison.Ordinal) && message.Contains("pos=2", StringComparison.Ordinal), + "should log position 2 for second iteration"); + snapshot.Should().Contain(message => message.Contains("[xsl:message] [DBG] for-each line=10", StringComparison.Ordinal) && message.Contains("pos=3", StringComparison.Ordinal), + "should log position 3 for third iteration"); + + // Verify for-each variable was captured + // Note: The variable will contain the LAST for-each that executed + // Since this test file has two for-each loops, it will be line 17 (the second one) + XsltEngineManager.Variables.Should().ContainKey("for-each"); + XsltEngineManager.Variables["for-each"].Should().NotBeNull(); + var forEachValue = XsltEngineManager.Variables["for-each"]!.ToString(); + (forEachValue.Contains("line=10") || forEachValue.Contains("line=17")).Should().BeTrue( + "for-each variable should contain either line 10 or line 17"); + + // Verify variable capture logging occurred (should see captures for BOTH loops) + snapshot.Should().Contain(message => message.Contains("Captured variable: $for-each", StringComparison.Ordinal) && message.Contains("line=10"), + "should log variable capture for line 10 for-each"); + snapshot.Should().Contain(message => message.Contains("Captured variable: $for-each", StringComparison.Ordinal) && message.Contains("line=17"), + "should log variable capture for line 17 for-each"); + } + finally + { + XsltEngineManager.EngineOutput -= OnOutput; + XsltEngineManager.EngineTerminated -= OnTerminated; + XsltEngineManager.Reset(); + } + } + + [Fact] + public async Task CompiledEngine_ShouldLogForEachPositionWithSort() + { + var stylesheetPath = GetTestDataPath("Integration/xslt/compiled/foreach-test.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/foreach-test.xml"); + var fullStylesheetPath = Path.GetFullPath(stylesheetPath); + var fullXmlPath = Path.GetFullPath(xmlPath); + + var engine = new XsltCompiledEngine(); + var outputLog = new List(); + var outputLock = new object(); + var terminatedSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnOutput(string message) + { + lock (outputLock) + { + outputLog.Add(message); + } + } + + void OnTerminated(int code) => terminatedSource.TrySetResult(code); + + XsltEngineManager.Reset(); + XsltEngineManager.SetDebugFlags(true, LogLevel.Log); + XsltEngineManager.EngineOutput += OnOutput; + XsltEngineManager.EngineTerminated += OnTerminated; + + try + { + await engine.StartAsync(fullStylesheetPath, fullXmlPath, stopOnEntry: false); + + var exitCode = await terminatedSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + exitCode.Should().Be(0, "transformation should complete successfully"); + + List snapshot; + lock (outputLock) + { + snapshot = outputLog.ToList(); + } + + // Verify for-each position logging for loop with sort (line 17) + snapshot.Should().Contain(message => message.Contains("[xsl:message] [DBG] for-each line=17", StringComparison.Ordinal) && message.Contains("select=/root/sorted/item", StringComparison.Ordinal) && message.Contains("pos=1", StringComparison.Ordinal), + "should log position 1 for first iteration (after sort)"); + snapshot.Should().Contain(message => message.Contains("[xsl:message] [DBG] for-each line=17", StringComparison.Ordinal) && message.Contains("pos=2", StringComparison.Ordinal), + "should log position 2 for second iteration (after sort)"); + snapshot.Should().Contain(message => message.Contains("[xsl:message] [DBG] for-each line=17", StringComparison.Ordinal) && message.Contains("pos=3", StringComparison.Ordinal), + "should log position 3 for third iteration (after sort)"); + + // Verify for-each variable was captured (will contain the last iteration's value) + XsltEngineManager.Variables.Should().ContainKey("for-each"); + XsltEngineManager.Variables["for-each"].Should().NotBeNull(); + XsltEngineManager.Variables["for-each"]!.ToString().Should().Contain("line=17"); + XsltEngineManager.Variables["for-each"]!.ToString().Should().Contain("select=/root/sorted/item"); + } + finally + { + XsltEngineManager.EngineOutput -= OnOutput; + XsltEngineManager.EngineTerminated -= OnTerminated; + XsltEngineManager.Reset(); + } + } + + [Fact] + public async Task CompiledEngine_ShouldLogForEachInShipmentConfv1() + { + var stylesheetPath = GetTestDataPath("Integration/xslt/compiled/ShipmentConfv1.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/ShipmentConf-proper.xml"); + var fullStylesheetPath = Path.GetFullPath(stylesheetPath); + var fullXmlPath = Path.GetFullPath(xmlPath); + + var engine = new XsltCompiledEngine(); + var outputLog = new List(); + var outputLock = new object(); + var terminatedSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnOutput(string message) + { + lock (outputLock) + { + outputLog.Add(message); + } + } + + void OnTerminated(int code) => terminatedSource.TrySetResult(code); + + XsltEngineManager.Reset(); + XsltEngineManager.SetDebugFlags(true, LogLevel.Log); + XsltEngineManager.EngineOutput += OnOutput; + XsltEngineManager.EngineTerminated += OnTerminated; + + try + { + await engine.StartAsync(fullStylesheetPath, fullXmlPath, stopOnEntry: false); + + var exitCode = await terminatedSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + exitCode.Should().Be(0, "transformation should complete successfully"); + + List snapshot; + lock (outputLock) + { + snapshot = outputLog.ToList(); + } + + // Verify for-each position logging for outer loop (line 60) + snapshot.Should().Contain(message => message.Contains("[xsl:message] [DBG] for-each line=60", StringComparison.Ordinal) && message.Contains("select=/ShipmentConfirmation/Orders/OrderItems", StringComparison.Ordinal), + "should log position for outer for-each at line 60"); + + // Verify for-each position logging for nested loop (line 83) + snapshot.Should().Contain(message => message.Contains("[xsl:message] [DBG] for-each line=83", StringComparison.Ordinal) && message.Contains("select=OperationReports/ReportInfo/OperationReportDate", StringComparison.Ordinal), + "should log position for nested for-each at line 83"); + + // Verify the existing xsl:message at line 67 is still present + snapshot.Should().Contain(message => message.Contains("[xsl:message] Hello", StringComparison.Ordinal), + "should preserve existing xsl:message elements"); + + // Verify for-each variable was captured + XsltEngineManager.Variables.Should().ContainKey("for-each"); + XsltEngineManager.Variables["for-each"].Should().NotBeNull(); + // The variable will contain the last for-each that executed (could be line 60 or line 83) + var forEachValue = XsltEngineManager.Variables["for-each"]!.ToString(); + (forEachValue.Contains("line=60") || forEachValue.Contains("line=83")).Should().BeTrue( + "for-each variable should contain either line 60 or line 83"); + } + finally + { + XsltEngineManager.EngineOutput -= OnOutput; + XsltEngineManager.EngineTerminated -= OnTerminated; + XsltEngineManager.Reset(); + } + } + private static void TryDeleteOutput(string outFile, string outDir) { try diff --git a/XsltDebugger.Tests/SaxonEngineIntegrationTests.cs b/XsltDebugger.Tests/SaxonEngineIntegrationTests.cs index c4f4527..c9dcfbb 100644 --- a/XsltDebugger.Tests/SaxonEngineIntegrationTests.cs +++ b/XsltDebugger.Tests/SaxonEngineIntegrationTests.cs @@ -82,8 +82,8 @@ void OnOutput(string message) [Fact] public async Task SaxonEngine_ShouldCaptureVariablesAndHitBreakpoints() { - var stylesheetPath = GetTestDataPath("Integration/VariableLoggingSample.xslt"); - var xmlPath = GetTestDataPath("Integration/ItemsSample.xml"); + var stylesheetPath = GetTestDataPath("Integration/xslt/saxon/VariableLoggingSample.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/ItemsSample.xml"); var fullStylesheetPath = Path.GetFullPath(stylesheetPath); var fullXmlPath = Path.GetFullPath(xmlPath); var breakpoints = new[] { (fullStylesheetPath, 8) }; @@ -156,11 +156,85 @@ void OnStopped(string file, int line, DebugStopReason reason) } } + [Fact] + public async Task SaxonEngine_ShouldCaptureVariables_WhenRunningXslt1Stylesheet() + { + var stylesheetPath = GetTestDataPath("Integration/xslt/compiled/VariableLoggingSampleV1.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/ItemsSample.xml"); + var fullStylesheetPath = Path.GetFullPath(stylesheetPath); + var fullXmlPath = Path.GetFullPath(xmlPath); + var breakpoints = new[] { (fullStylesheetPath, 8) }; + + var engine = new SaxonEngine(); + var outputLog = new List(); + var outputLock = new object(); + var breakpointHitSource = new TaskCompletionSource<(string file, int line, DebugStopReason reason)>(TaskCreationOptions.RunContinuationsAsynchronously); + var terminatedSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + void OnOutput(string message) + { + lock (outputLock) + { + outputLog.Add(message); + } + } + void OnStopped(string file, int line, DebugStopReason reason) + { + breakpointHitSource.TrySetResult((file, line, reason)); + _ = engine.ContinueAsync(); + } + void OnTerminated(int code) => terminatedSource.TrySetResult(code); + + XsltEngineManager.Reset(); + XsltEngineManager.SetDebugFlags(true, LogLevel.TraceAll); + XsltEngineManager.EngineOutput += OnOutput; + XsltEngineManager.EngineStopped += OnStopped; + XsltEngineManager.EngineTerminated += OnTerminated; + + try + { + engine.SetBreakpoints(breakpoints); + await engine.StartAsync(fullStylesheetPath, fullXmlPath, stopOnEntry: false); + + var breakpointHit = await breakpointHitSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + breakpointHit.file.Should().Be(fullStylesheetPath); + breakpointHit.line.Should().Be(8); + breakpointHit.reason.Should().Be(DebugStopReason.Breakpoint); + + var exitCode = await terminatedSource.Task.WaitAsync(TimeSpan.FromSeconds(5)); + exitCode.Should().Be(0, "successful transformation should terminate with 0 exit code"); + + XsltEngineManager.Variables.Should().ContainKey("count"); + XsltEngineManager.Variables["count"].Should().Be("2"); + + XsltEngineManager.Variables.Should().ContainKey("firstName"); + XsltEngineManager.Variables["firstName"].Should().Be("Alpha"); + + List snapshot; + lock (outputLock) + { + snapshot = outputLog.ToList(); + } + + snapshot.Should().Contain(message => message.Contains("[debug] Instrumenting 2 variable", StringComparison.OrdinalIgnoreCase)); + snapshot.Should().Contain(message => message.Contains("[debug] Instrumented variable: $count", StringComparison.OrdinalIgnoreCase)); + snapshot.Should().Contain(message => message.Contains("[debug] Instrumented variable: $firstName", StringComparison.OrdinalIgnoreCase)); + snapshot.Should().Contain(message => message.Contains("[DBG] count", StringComparison.OrdinalIgnoreCase)); + } + finally + { + XsltEngineManager.EngineOutput -= OnOutput; + XsltEngineManager.EngineStopped -= OnStopped; + XsltEngineManager.EngineTerminated -= OnTerminated; + XsltEngineManager.Reset(); + } + } + [Fact] public async Task SaxonEngine_ShouldReportCompilationErrors() { - var stylesheetPath = GetTestDataPath("Integration/InvalidFunctionCall.xslt"); - var xmlPath = GetTestDataPath("Integration/ItemsSample.xml"); + var stylesheetPath = GetTestDataPath("Integration/xslt/tests/InvalidFunctionCall.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/ItemsSample.xml"); var fullStylesheetPath = Path.GetFullPath(stylesheetPath); var fullXmlPath = Path.GetFullPath(xmlPath); var engine = new SaxonEngine(); @@ -210,8 +284,8 @@ void OnOutput(string message) [Fact] public async Task SaxonEngine_ShouldTransformShipmentSample_WithTraceLogging() { - var stylesheetPath = GetTestDataPath("Integration/ShipmentConf3.xslt"); - var xmlPath = GetTestDataPath("Integration/ShipmentConf-proper.xml"); + var stylesheetPath = GetTestDataPath("Integration/xslt/saxon/ShipmentConf3.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/ShipmentConf-proper.xml"); var fullStylesheetPath = Path.GetFullPath(stylesheetPath); var fullXmlPath = Path.GetFullPath(xmlPath); var engine = new SaxonEngine(); @@ -264,8 +338,8 @@ void OnOutput(string message) [Fact] public async Task SaxonEngine_ShouldRespectVariableInstrumentationGuardrails() { - var stylesheetPath = GetTestDataPath("Integration/test-guardrails.xslt"); - var xmlPath = GetTestDataPath("Integration/sample.xml"); + var stylesheetPath = GetTestDataPath("Integration/xslt/tests/test-guardrails.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/sample.xml"); var fullStylesheetPath = Path.GetFullPath(stylesheetPath); var fullXmlPath = Path.GetFullPath(xmlPath); var engine = new SaxonEngine(); @@ -321,7 +395,7 @@ void OnOutput(string message) [Fact] public async Task SaxonEngine_ShouldTransformAdvancedXslt2_WithInstrumentation() { - var (log, outFile, exitCode) = await RunSaxonAsync("Integration/AdvanceXslt2.xslt", "Integration/AdvanceFile.xml"); + var (log, outFile, exitCode) = await RunSaxonAsync("Integration/xslt/saxon/AdvanceXslt2.xslt", "Integration/xml/AdvanceFile.xml"); try { exitCode.Should().Be(0); @@ -344,7 +418,7 @@ public async Task SaxonEngine_ShouldTransformAdvancedXslt2_WithInstrumentation() [Fact] public async Task SaxonEngine_ShouldTransformAdvancedXslt3_WithAccumulatorInstrumentation() { - var (log, outFile, exitCode) = await RunSaxonAsync("Integration/AdvanceXslt3.xslt", "Integration/AdvanceFile.xml"); + var (log, outFile, exitCode) = await RunSaxonAsync("Integration/xslt/saxon/AdvanceXslt3.xslt", "Integration/xml/AdvanceFile.xml"); try { exitCode.Should().Be(0); diff --git a/XsltDebugger.Tests/StepIntoTests.cs b/XsltDebugger.Tests/StepIntoTests.cs index 9484a3e..0f8a43e 100644 --- a/XsltDebugger.Tests/StepIntoTests.cs +++ b/XsltDebugger.Tests/StepIntoTests.cs @@ -267,8 +267,8 @@ public async Task SaxonEngine_StepOver_ShouldStayAtSameDepth() [Fact] public async Task XsltCompiledEngine_StepOver_CallTemplate_ShouldPauseAfterReturn() { - var xsltPath = GetTestDataPath("Integration/step-into-test.xslt"); - var xmlPath = GetTestDataPath("Integration/step-into-test.xml"); + var xsltPath = GetTestDataPath("Integration/xslt/tests/step-into-test.xslt"); + var xmlPath = GetTestDataPath("Integration/xml/step-into-test.xml"); var lines = File.ReadAllLines(xsltPath); var callLine = Array.FindIndex(lines, line => line.Contains(@" line.Contains(@" line.Contains(@" line.Contains(@".instrumented.xslt` copy when trace logging is enabled. +- Supports inline C# through `msxsl:script`, compiling snippets with Roslyn, auto-registering extension namespaces, and optionally instrumenting generated helper code. + +### SaxonEngine +- Builds a Saxon HE/PE processor, enables XSLT 3.0 features, and compiles stylesheets to a reusable executable for full 2.0/3.0 behavior. +- Only instruments stylesheets when debugging is enabled, registering a custom Saxon extension function to surface probes. +- Blocks inline C# by reusing engine validation logic and reports static compilation issues (line/column) via Saxon’s `ErrorList`. +- Switches between XSLT 1.0-safe probes (value-of/message) and XSLT 2.0/3.0 probes (`xsl:sequence` with tuple logging) based on the stylesheet `version` attribute. + +## Shared Debug Surface +- Breakpoints: both inject `dbg:break()` calls, mark template entry/exit, and honor user breakpoints through shared stepping state (`StepMode` with depth tracking). +- Stepping: continue/step-in/step-over/step-out behaviors use identical synchronization and depth logic, pausing when template boundaries or requested lines are reached. +- Watches & Namespaces: both extract stylesheet namespace declarations (adding a synthetic `default` prefix) and register them so watch expressions resolve consistently. +- Pause Context: both convert the current execution node to an `XPathNavigator` clone before notifying the debug adapter, enabling expression evaluation at stop points. +- Message Capture: both parse `[DBG]` prefixed messages into the debugger’s variable store while forwarding ordinary `xsl:message` output to the console. + +## Instrumentation Differences + +### XsltCompiledEngine Specialties +- Probes are injected as `xsl:value-of` calls to stay XSLT 1.0 compliant and include supplemental `xsl:message` blocks for `xsl:for-each` position reporting. +- Variable instrumentation groups declarations per template, inserting `[DBG]` messages after the final `xsl:param`/`xsl:variable` to preserve 1.0 ordering rules. +- Inline Roslyn extension objects allow breakpoints and logging inside user scripts, providing parity with template-level debugging for hybrid XSLT/C# projects. + +### SaxonEngine Specialties +- Probe nodes are created as `xsl:sequence` instructions tagged with `dbg:probe="1"`, allowing the engine to skip reinstrumentation on reentry. +- Maintains extensive β€œfragile” element lists (e.g., `iterate`, `try/catch`, `accumulator-rule`, `merge-*`) so instrumentation never invalidates XSLT 2.0/3.0 constructs. +- Variable probes emit tuples via `xsl:message select="('[DBG]', ...)"`, leveraging 2.0 expressions (`string-join`) to flatten sequences and capture multi-item variables. +- Serializes Saxon `XdmNode` contexts back into .NET DOMs to give the debugger XPath-compatible navigation without exposing Saxon internals. + +## Feature Gap Snapshot + +| Feature Area | Only in XsltCompiledEngine | Only in SaxonEngine | +| --- | --- | --- | +| XSLT version focus | Inline C# compatible XSLT 1.0 runtime | XSLT 1.0 (no inline C#) plus native XSLT 2.0/3.0 execution | +| Inline scripting | Compiles `msxsl:script` via Roslyn and instruments generated code | Explicitly blocked; prompts user to switch engines | +| Instrumentation safety | Tailored to 1.0 structural rules and limited fragile contexts | Expansive guards for 2.0/3.0 constructs (accumulators, iterate/try, merge) with `dbg:probe` markers | +| Variable capture | Text/value-of messages per template, respecting 1.0 ordering | Tuple/sequence messages with `string-join` across sequences | +| Compilation diagnostics | Relies on transform exceptions | Surfaces Saxon static errors with module URI, line, and column | + +## Selection Guidance +- Choose **XsltCompiledEngine** for XSLT 1.0 stylesheets that require inline C# or when targeting the .NET runtime exclusively. +- Choose **SaxonEngine** for XSLT 1.0 stylesheets without `msxsl:script`, XSLT 2.0/3.0 transformations, schema-aware features, or when richer compile-time diagnostics and sequence-aware variable logging are required. +- Maintain separate launch configurations so each stylesheet uses the engine aligned with its language level and extension requirements. diff --git a/package-lock.json b/package-lock.json index 791ebd3..25186b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "xsltdebugger", - "version": "0.0.1", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "xsltdebugger", - "version": "0.0.1", + "version": "1.0.0", "devDependencies": { "@types/mocha": "^10.0.10", "@types/node": "22.x", diff --git a/package.json b/package.json index c2654d0..865ed71 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "xsltdebugger", - "displayName": "XSLT Debugger", + "name": "xsltdebugger-darwin", + "displayName": "XSLT Debugger for macOS-arm64", "description": "A VS Code extension for debugging XSLT using a .NET Debug Adapter.", - "version": "0.6.0", + "version": "1.0.0", "publisher": "DanielJonathan", "repository": { "type": "git", diff --git a/test-namespace-support.sh b/test-namespace-support.sh new file mode 100755 index 0000000..97d7e3e --- /dev/null +++ b/test-namespace-support.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +echo "==================================" +echo "XSLT Namespace Support Tests" +echo "==================================" +echo "" + +# Test 1: LmlBasedXslt (Logic Apps generated) +echo "Test 1: LmlBasedXslt.xslt (Logic Apps, XSLT 3.0 with namespaces)" +echo "----------------------------------------" +dotnet run --project XsltDebugger.ConsoleTest/XsltDebugger.ConsoleTest.csproj -- \ + LmlBasedXslt.xslt ShipmentConf-lml.xml --engine saxon 2>&1 | \ + grep -A 20 "REGISTERED NAMESPACES" | head -25 +echo "" + +# Test 2: AdvanceXslt2 (no namespaces in XML) +echo "Test 2: AdvanceXslt2.xslt (XSLT 2.0, XML without namespaces)" +echo "----------------------------------------" +dotnet run --project XsltDebugger.ConsoleTest/XsltDebugger.ConsoleTest.csproj -- \ + AdvanceXslt2.xslt AdvanceFile.xml --engine saxon 2>&1 | \ + grep -A 20 "REGISTERED NAMESPACES" | head -25 +echo "" + +# Test 3: ShipmentConf3 (XSLT 3.0) +echo "Test 3: ShipmentConf3.xslt (XSLT 3.0 with custom functions)" +echo "----------------------------------------" +dotnet run --project XsltDebugger.ConsoleTest/XsltDebugger.ConsoleTest.csproj -- \ + ShipmentConf3.xslt ShipmentConf-proper.xml --engine saxon 2>&1 | \ + grep -A 20 "REGISTERED NAMESPACES" | head -25 +echo "" + +# Test 4: VariableLoggingSampleV1 (XSLT 1.0) +echo "Test 4: VariableLoggingSampleV1.xslt (XSLT 1.0)" +echo "----------------------------------------" +dotnet run --project XsltDebugger.ConsoleTest/XsltDebugger.ConsoleTest.csproj -- \ + VariableLoggingSampleV1.xslt VariableLoggingSampleV1Input.xml --engine compiled 2>&1 | \ + grep -A 20 "REGISTERED NAMESPACES" | head -25 +echo "" + +echo "==================================" +echo "All tests complete!" +echo "=================================="