From 64f3f71211ed91f7719708941357fa94498dba37 Mon Sep 17 00:00:00 2001 From: Mihaela M Date: Sun, 28 Jun 2026 22:25:48 +0200 Subject: [PATCH] fix(parsing): keep a non-core-tagged scalar as a string A node with an explicit tag is not subject to implicit plain-scalar resolution, but composeScalarValue fell through to it whenever the tag was not a recognized core type. So a local tag (`!foo`), the non-specific `!`, or an unknown verbatim URI still let `! 12` resolve to the integer 12 and `!Thing false` to a boolean. When a tag is present but not a core type, fix the node as the string of its content instead of resolving it. Recognized core tags and untagged scalars are unchanged. Two compatibility tests asserted the old behavior (a custom- tagged `true` as a boolean); they are corrected to expect the string, the behavior the yaml-test-suite case S4JQ requires. Raises yaml-test-suite value-level conformance to 188/194 (96.9%) with the parse-level and should-error rejection rates unchanged. 452 tests pass. --- CHANGELOG.md | 8 +++++ .../Parsing/Parser.ScalarComposition.swift | 7 ++++ Tests/NonCoreTagScalarResolutionTests.swift | 36 +++++++++++++++++++ Tests/ParsingCompatibilityTests.swift | 4 ++- Tests/TaggedCompatibilityTests.swift | 4 ++- 5 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 Tests/NonCoreTagScalarResolutionTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index c073da6..51ff6c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -84,6 +84,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A scalar carrying an explicit non-core tag now stays a string instead of being implicitly resolved. + A node with a tag is not subject to plain-scalar resolution, so a local tag (`!foo`), the + non-specific `!`, or an unknown verbatim URI fixes the node as the string of its content: `! 12` is + the string `12`, not the integer, and `!Thing false` is the string `false`, not a boolean. + Recognized core tags (`!!int`, `!!bool`, ...) and untagged scalars still resolve. Two compatibility + tests that asserted the previous behavior were corrected. Raises yaml-test-suite value-level + conformance to 188/194 (96.9%), with the parse and should-error rates unchanged. + - A tab before a `#` now ends a plain scalar and begins a trailing comment, as a space already did, since a comment opens after any white space. `c: d\t#X` is the value `d` with a comment, not the literal `d\t#X`. A tab inside a plain scalar with no `#` after it stays part of the value, and a `#` diff --git a/Sources/Parsing/Parser.ScalarComposition.swift b/Sources/Parsing/Parser.ScalarComposition.swift index a74d68f..84b973d 100644 --- a/Sources/Parsing/Parser.ScalarComposition.swift +++ b/Sources/Parsing/Parser.ScalarComposition.swift @@ -9,6 +9,13 @@ extension PureYAML.Parsing.Parser { return tagged } + // An explicit tag that is not a recognized core type, a local tag like `!foo` or the + // non-specific `!`, still fixes the node as a string: a tagged scalar is not subject to the + // implicit plain-scalar resolution that would otherwise read `! 12` as the integer 12. + if normalizedTag(tag) != nil { + return .string(value) + } + switch style { case .folded, .literal: return .string(value) diff --git a/Tests/NonCoreTagScalarResolutionTests.swift b/Tests/NonCoreTagScalarResolutionTests.swift new file mode 100644 index 0000000..b9550e4 --- /dev/null +++ b/Tests/NonCoreTagScalarResolutionTests.swift @@ -0,0 +1,36 @@ +@testable import PureYAML +import Testing + +/// A scalar carrying an explicit tag is not subject to implicit plain-scalar resolution. A recognized +/// core tag (`!!int`, `!!bool`, ...) resolves to that type, but a local tag (`!foo`), the +/// non-specific `!`, or an unknown verbatim URI fixes the node as the string of its content: `! 12` +/// is the string `12`, not the integer. +@Suite("Non-core tag scalar resolution") +struct NonCoreTagScalarResolutionTests { + @Test("The non-specific ! tag keeps a number as a string") + func test_nonSpecificTag() throws { + #expect(try PureYAML.parse("- ! 12\n") == .sequence([.string("12")])) + } + + @Test("A local tag keeps a scalar as a string") + func test_localTag() throws { + #expect(try PureYAML.parse("- !foo 12\n") == .sequence([.string("12")])) + #expect(try PureYAML.parse("flag: !custom true\n") == .mapping(.init([ + .init(key: "flag", value: .string("true")), + ]))) + } + + @Test("An unknown verbatim URI tag keeps a scalar as a string") + func test_unknownVerbatimTag() throws { + #expect(try PureYAML.parse("x: ! true\n") == .mapping(.init([ + .init(key: "x", value: .string("true")), + ]))) + } + + @Test("Recognized core tags and untagged scalars still resolve") + func test_coreTagsStillResolve() throws { + #expect(try PureYAML.parse("- !!int 12\n") == .sequence([.int(12)])) + #expect(try PureYAML.parse("- !!bool true\n") == .sequence([.bool(true)])) + #expect(try PureYAML.parse("- 12\n") == .sequence([.int(12)])) + } +} diff --git a/Tests/ParsingCompatibilityTests.swift b/Tests/ParsingCompatibilityTests.swift index b2e9f0f..5903735 100644 --- a/Tests/ParsingCompatibilityTests.swift +++ b/Tests/ParsingCompatibilityTests.swift @@ -220,7 +220,9 @@ struct ParsingCompatibilityTests { #expect(root?["ratio"] == .double(3.5)) #expect(root?["enabled"] == .bool(false)) #expect(root?["missing"] == .null) - #expect(root?["custom"] == .bool(true)) + // A custom (non-core) tag excludes the node from implicit resolution, so the scalar content + // stays a string: `true` is not read as a boolean under an application-specific tag. + #expect(root?["custom"] == .string("true")) } private func expectScalar( diff --git a/Tests/TaggedCompatibilityTests.swift b/Tests/TaggedCompatibilityTests.swift index a849c0b..d690da9 100644 --- a/Tests/TaggedCompatibilityTests.swift +++ b/Tests/TaggedCompatibilityTests.swift @@ -25,7 +25,9 @@ struct TaggedCompatibilityTests { #expect(firstItem?.tag == .string) #expect(firstItem?.value == .string("true")) #expect(secondItem?.tag == .init("!Thing")) - #expect(secondItem?.value == .bool(false)) + // The `!Thing` local tag is preserved, and it excludes the scalar from implicit resolution, + // so its value stays the string `false` rather than being read as a boolean. + #expect(secondItem?.value == .string("false")) #expect(PureYAML.Tagged.Validator().collect(.mapping(root ?? .init(pairs: []))) == .init()) }