diff --git a/Sources/SWBCore/MacroConfigFileLoader.swift b/Sources/SWBCore/MacroConfigFileLoader.swift index 2d5e4248..ba7a99b2 100644 --- a/Sources/SWBCore/MacroConfigFileLoader.swift +++ b/Sources/SWBCore/MacroConfigFileLoader.swift @@ -242,7 +242,7 @@ final class MacroConfigFileLoader: Sendable { return MacroConfigFileParser(byteString: data, path: path, delegate: delegate) } - mutating func foundMacroValueAssignment(_ macroName: String, conditions: [(param: String, pattern: String)], value: String, parser: MacroConfigFileParser) { + mutating func foundMacroValueAssignment(_ macroName: String, conditions: [(param: String, pattern: String)], value: String, path: Path, line: Int, startColumn: Int, endColumn: Int, parser: MacroConfigFileParser) { // Look up the macro name, creating it as a user-defined macro if it isn’t already known. let macro = table.namespace.lookupOrDeclareMacro(UserDefinedMacroDeclaration.self, macroName) @@ -253,7 +253,8 @@ final class MacroConfigFileLoader: Sendable { } // Parse the value in a manner consistent with the macro definition. - table.push(macro, table.namespace.parseForMacro(macro, value: value), conditions: conditionSet) + let location = MacroValueAssignmentLocation(path: path, line: line, startColumn: startColumn, endColumn: endColumn) + table.push(macro, table.namespace.parseForMacro(macro, value: value), conditions: conditionSet, location: location) } func handleDiagnostic(_ diagnostic: MacroConfigFileDiagnostic, parser: MacroConfigFileParser) { @@ -301,8 +302,8 @@ fileprivate final class MacroValueAssignmentTableRef { table.namespace } - func push(_ macro: MacroDeclaration, _ value: MacroExpression, conditions: MacroConditionSet? = nil) { - table.push(macro, value, conditions: conditions) + func push(_ macro: MacroDeclaration, _ value: MacroExpression, conditions: MacroConditionSet? = nil, location: MacroValueAssignmentLocation? = nil) { + table.push(macro, value, conditions: conditions, location: location) } } diff --git a/Sources/SWBMacro/MacroConfigFileParser.swift b/Sources/SWBMacro/MacroConfigFileParser.swift index a1fa7ebf..00453277 100644 --- a/Sources/SWBMacro/MacroConfigFileParser.swift +++ b/Sources/SWBMacro/MacroConfigFileParser.swift @@ -276,6 +276,7 @@ public final class MacroConfigFileParser { // MARK: Parsing of value assignment starts here. /// Parses a macro value assignment line of the form MACRONAME [ optional conditions ] ... = VALUE ';'? private func parseMacroValueAssignment() { + let startOfLine = currIdx - 1 // First skip over any whitespace and comments. skipWhitespaceAndComments() @@ -361,6 +362,7 @@ public final class MacroConfigFileParser { // Skip over the equals sign. assert(currChar == /* '=' */ 61) advance() + let startColumn = currIdx - startOfLine var chunks : [String] = [] while let chunk = parseNonListAssignmentRHS() { @@ -383,7 +385,7 @@ public final class MacroConfigFileParser { } // Finally, now that we have the name, conditions, and value, we tell the delegate about it. let value = chunks.joined(separator: " ") - delegate?.foundMacroValueAssignment(name, conditions: conditions, value: value, parser: self) + delegate?.foundMacroValueAssignment(name, conditions: conditions, value: value, path: path, line: currLine, startColumn: startColumn, endColumn: currIdx - startOfLine, parser: self) } public func parseNonListAssignmentRHS() -> String? { @@ -518,7 +520,7 @@ public final class MacroConfigFileParser { } func endPreprocessorInclusion() { } - func foundMacroValueAssignment(_ macroName: String, conditions: [(param: String, pattern: String)], value: String, parser: MacroConfigFileParser) { + func foundMacroValueAssignment(_ macroName: String, conditions: [(param: String, pattern: String)], value: String, path: Path, line: Int, startColumn: Int, endColumn: Int, parser: MacroConfigFileParser) { self.macroName = macroName self.conditions = conditions.isEmpty ? nil : conditions } @@ -565,7 +567,7 @@ public protocol MacroConfigFileParserDelegate { func endPreprocessorInclusion() /// Invoked once for each macro value assignment. The `macroName` is guaranteed to be non-empty, but `value` may be empty. Any macro conditions are passed as tuples in the `conditions`; parameters are guaranteed to be non-empty strings, but patterns may be empty. - mutating func foundMacroValueAssignment(_ macroName: String, conditions: [(param: String, pattern: String)], value: String, parser: MacroConfigFileParser) + mutating func foundMacroValueAssignment(_ macroName: String, conditions: [(param: String, pattern: String)], value: String, path: Path, line: Int, startColumn: Int, endColumn: Int, parser: MacroConfigFileParser) /// Invoked if an error, warning, or other diagnostic is detected. func handleDiagnostic(_ diagnostic: MacroConfigFileDiagnostic, parser: MacroConfigFileParser) diff --git a/Sources/SWBMacro/MacroValueAssignmentTable.swift b/Sources/SWBMacro/MacroValueAssignmentTable.swift index 84a6f962..6586fe43 100644 --- a/Sources/SWBMacro/MacroValueAssignmentTable.swift +++ b/Sources/SWBMacro/MacroValueAssignmentTable.swift @@ -20,18 +20,23 @@ public struct MacroValueAssignmentTable: Serializable, Sendable { /// Maps macro declarations to corresponding linked lists of assignments. public var valueAssignments: [MacroDeclaration: MacroValueAssignment] - private init(namespace: MacroNamespace, valueAssignments: [MacroDeclaration: MacroValueAssignment]) { + private var valueLocations: [String: InternedMacroValueAssignmentLocation] + private var macroConfigPaths: OrderedSet + + private init(namespace: MacroNamespace, valueAssignments: [MacroDeclaration: MacroValueAssignment], valueLocations: [String: InternedMacroValueAssignmentLocation], macroConfigPaths: OrderedSet) { self.namespace = namespace self.valueAssignments = valueAssignments + self.valueLocations = valueLocations + self.macroConfigPaths = macroConfigPaths } public init(namespace: MacroNamespace) { - self.init(namespace: namespace, valueAssignments: [:]) + self.init(namespace: namespace, valueAssignments: [:], valueLocations: [:], macroConfigPaths: OrderedSet()) } /// Convenience initializer to create a `MacroValueAssignmentTable` from another instance (i.e., to create a copy). public init(copying table: MacroValueAssignmentTable) { - self.init(namespace: table.namespace, valueAssignments: table.valueAssignments) + self.init(namespace: table.namespace, valueAssignments: table.valueAssignments, valueLocations: table.valueLocations, macroConfigPaths: table.macroConfigPaths) } /// Remove all assignments for the given macro. @@ -77,11 +82,24 @@ public struct MacroValueAssignmentTable: Serializable, Sendable { /// Adds a mapping from `macro` to `value`, inserting it ahead of any already existing assignment for the same macro. Unless the value refers to the lower-precedence expression (using `$(inherited)` notation), any existing assignments are shadowed but not removed. - public mutating func push(_ macro: MacroDeclaration, _ value: MacroExpression, conditions: MacroConditionSet? = nil) { + public mutating func push(_ macro: MacroDeclaration, _ value: MacroExpression, conditions: MacroConditionSet? = nil, location: MacroValueAssignmentLocation? = nil) { assert(namespace.lookupMacroDeclaration(macro.name) === macro) // Validate the type. assert(macro.type.matchesExpressionType(value)) valueAssignments[macro] = MacroValueAssignment(expression: value, conditions: conditions, next: valueAssignments[macro]) + + if let location { + let index = macroConfigPaths.append(location.path).index + valueLocations[macro.name] = InternedMacroValueAssignmentLocation(pathRef: index, line: location.line, startColumn: location.startColumn, endColumn: location.endColumn) + } + } + + private mutating func mergeLocations(from otherTable: MacroValueAssignmentTable) { + otherTable.valueLocations.forEach { + let path = otherTable.macroConfigPaths[$0.value.pathRef] + let index = macroConfigPaths.append(path).index + valueLocations[$0.key] = .init(pathRef: index, line: $0.value.line, startColumn: $0.value.startColumn, endColumn: $0.value.endColumn) + } } /// Adds a mapping from each of the macro-to-value mappings in `otherTable`, inserting them ahead of any already existing assignments in the receiving table. The other table isn’t affected in any way (in particular, no reference is kept from the receiver to the other table). @@ -89,6 +107,7 @@ public struct MacroValueAssignmentTable: Serializable, Sendable { for (macro, firstAssignment) in otherTable.valueAssignments { valueAssignments[macro] = insertCopiesOfMacroValueAssignmentNodes(firstAssignment, inFrontOf: valueAssignments[macro]) } + mergeLocations(from: otherTable) } /// Looks up and returns the first (highest-precedence) macro value assignment for `macro`, if there is one. @@ -106,6 +125,18 @@ public struct MacroValueAssignmentTable: Serializable, Sendable { return valueAssignments.isEmpty } + public func location(of macro: MacroDeclaration) -> MacroValueAssignmentLocation? { + guard let location = valueLocations[macro.name] else { + return nil + } + return MacroValueAssignmentLocation( + path: macroConfigPaths[location.pathRef], + line: location.line, + startColumn: location.startColumn, + endColumn: location.endColumn + ) + } + public func bindConditionParameter(_ parameter: MacroConditionParameter, _ conditionValues: [String]) -> MacroValueAssignmentTable { return bindConditionParameter(parameter, conditionValues.map { .string($0) }) } @@ -192,6 +223,7 @@ public struct MacroValueAssignmentTable: Serializable, Sendable { bindAndPushAssignment(firstAssignment) } + table.mergeLocations(from: self) return table } @@ -219,7 +251,7 @@ public struct MacroValueAssignmentTable: Serializable, Sendable { // MARK: Serialization public func serialize(to serializer: T) { - serializer.beginAggregate(1) + serializer.beginAggregate(3) // We don't directly serialize MacroDeclarations, but rather serialize their contents "by hand" so when we deserialize we can re-use existing declarations in our namespace. serializer.beginAggregate(valueAssignments.count) @@ -247,6 +279,17 @@ public struct MacroValueAssignmentTable: Serializable, Sendable { } serializer.endAggregate() // valueAssignments + serializer.beginAggregate(valueLocations.count) + for (decl, loc) in valueLocations.sorted(by: { $0.0 < $1.0 }) { + serializer.beginAggregate(2) + serializer.serialize(decl) + serializer.serialize(loc) + serializer.endAggregate() + } + serializer.endAggregate() + + serializer.serialize(macroConfigPaths) + serializer.endAggregate() // the whole table } @@ -255,9 +298,10 @@ public struct MacroValueAssignmentTable: Serializable, Sendable { guard let delegate = deserializer.delegate as? (any MacroValueAssignmentTableDeserializerDelegate) else { throw DeserializerError.invalidDelegate("delegate must be a MacroValueAssignmentTableDeserializerDelegate") } self.namespace = delegate.namespace self.valueAssignments = [:] + self.valueLocations = [:] // Deserialize the table. - try deserializer.beginAggregate(1) + try deserializer.beginAggregate(3) // Iterate over all the key-value pairs. let count: Int = try deserializer.beginAggregate() @@ -304,6 +348,16 @@ public struct MacroValueAssignmentTable: Serializable, Sendable { // Add it to the dictionary. self.valueAssignments[decl] = asgn } + + let count2 = try deserializer.beginAggregate() + for _ in 0...Index + let line: Int + let startColumn: Int + let endColumn: Int + + init(pathRef: OrderedSet.Index, line: Int, startColumn: Int, endColumn: Int) { + self.pathRef = pathRef + self.line = line + self.startColumn = startColumn + self.endColumn = endColumn + } + + public func serialize(to serializer: T) where T : SWBUtil.Serializer { + serializer.beginAggregate(4) + serializer.serialize(pathRef) + serializer.serialize(line) + serializer.serialize(startColumn) + serializer.serialize(endColumn) + serializer.endAggregate() + } + + public init(from deserializer: any SWBUtil.Deserializer) throws { + try deserializer.beginAggregate(4) + self.pathRef = try deserializer.deserialize() + self.line = try deserializer.deserialize() + self.startColumn = try deserializer.deserialize() + self.endColumn = try deserializer.deserialize() + } +} + /// Private function that inserts a copy of the given linked list of MacroValueAssignments (starting at `srcAsgn`) in front of `dstAsgn` (which is optional). The order of the copies is the same as the order of the originals, and the last one will have `dstAsgn` as its `next` property. This function returns the copy that corresponds to `srcAsgn` so the client can add a reference to it wherever it sees fit. private func insertCopiesOfMacroValueAssignmentNodes(_ srcAsgn: MacroValueAssignment, inFrontOf dstAsgn: MacroValueAssignment?) -> MacroValueAssignment { // If we aren't inserting in front of anything, we can preserve the input as is. diff --git a/Tests/SWBCoreTests/SettingsTests.swift b/Tests/SWBCoreTests/SettingsTests.swift index 47f6cf6a..f392d22b 100644 --- a/Tests/SWBCoreTests/SettingsTests.swift +++ b/Tests/SWBCoreTests/SettingsTests.swift @@ -134,6 +134,7 @@ import SWBMacro // Verify that the settings from the xcconfig were added. let XCCONFIG_USER_SETTING = try #require(settings.userNamespace.lookupMacroDeclaration("XCCONFIG_USER_SETTING")) #expect(settings.tableForTesting.lookupMacro(XCCONFIG_USER_SETTING)?.expression.stringRep == "from-xcconfig") + #expect(settings.tableForTesting.location(of: XCCONFIG_USER_SETTING) == MacroValueAssignmentLocation(path: .init("/tmp/xcconfigs/Base0.xcconfig"), line: 1, startColumn: 24, endColumn: 38)) // Verify the user project settings. let USER_PROJECT_SETTING = try #require(settings.userNamespace.lookupMacroDeclaration("USER_PROJECT_SETTING")) diff --git a/Tests/SWBMacroTests/MacroParsingTests.swift b/Tests/SWBMacroTests/MacroParsingTests.swift index 7e6778b7..e639ec4e 100644 --- a/Tests/SWBMacroTests/MacroParsingTests.swift +++ b/Tests/SWBMacroTests/MacroParsingTests.swift @@ -790,7 +790,7 @@ fileprivate let testFileData = [ } func endPreprocessorInclusion() { } - func foundMacroValueAssignment(_ macroName: String, conditions: [(param: String, pattern: String)], value: String, parser: MacroConfigFileParser) { + func foundMacroValueAssignment(_ macroName: String, conditions: [(param: String, pattern: String)], value: String, path: Path, line: Int, startColumn: Int, endColumn: Int, parser: MacroConfigFileParser) { } func handleDiagnostic(_ diagnostic: MacroConfigFileDiagnostic, parser: MacroConfigFileParser) { @@ -804,19 +804,41 @@ fileprivate let testFileData = [ MacroConfigFileParser(byteString: "// [-Wnullability-completeness-on-arrays] \t\t\t(on) Warns about missing nullability annotations on array parameters.", path: Path(""), delegate: delegate).parse() #expect(delegate.diagnosticMessages == [String]()) } + + @Test + func parserProvidesLocationInformation() throws { + TestMacroConfigFileParser("#include \"Multiline.xcconfig\"", + expectedAssignments: [ + (macro: "FEATURE_DEFINES_A", conditions: [], value: "$(A) $(B) $(C)"), + (macro: "FEATURE_DEFINES_B", conditions: [], value: "$(D) $(E) $(F)"), + (macro: "FEATURE_DEFINES_C", conditions: [], value: "$(G) $(H)"), + (macro: "FEATURE_DEFINES_D", conditions: [], value: "$(I)") + ], + expectedDiagnostics: [], + expectedLocations: [ + (macro: "FEATURE_DEFINES_A", path: .init("Multiline.xcconfig"), line: 2, startColumn: 20, endColumn: 37), + (macro: "FEATURE_DEFINES_B", path: .init("Multiline.xcconfig"), line: 5, startColumn: 20, endColumn: 87), + (macro: "FEATURE_DEFINES_C", path: .init("Multiline.xcconfig"), line: 9, startColumn: 20, endColumn: 61), + (macro: "FEATURE_DEFINES_D", path: .init("Multiline.xcconfig"), line: 11, startColumn: 20, endColumn: 45), + ], + expectedIncludeDirectivesCount: 1 + ) + } } // We used typealiased tuples for simplicity and readability. typealias ConditionInfo = (param: String, pattern: String) typealias AssignmentInfo = (macro: String, conditions: [ConditionInfo], value: String) typealias DiagnosticInfo = (level: MacroConfigFileDiagnostic.Level, kind: MacroConfigFileDiagnostic.Kind, line: Int) +typealias LocationInfo = (macro: String, path: Path, line: Int, startColumn: Int, endColumn: Int) -private func TestMacroConfigFileParser(_ string: String, expectedAssignments: [AssignmentInfo], expectedDiagnostics: [DiagnosticInfo], expectedIncludeDirectivesCount: Int, sourceLocation: SourceLocation = #_sourceLocation) { +private func TestMacroConfigFileParser(_ string: String, expectedAssignments: [AssignmentInfo], expectedDiagnostics: [DiagnosticInfo], expectedLocations: [LocationInfo]? = nil, expectedIncludeDirectivesCount: Int, sourceLocation: SourceLocation = #_sourceLocation) { /// We use a custom delegate to test that we’re getting the expected results, which for the sake of convenience are just kept in (name, conds:[(cond-param, cond-value)], value) tuples, i.e. conditions is an array of two-element tuples. class ConfigFileParserTestDelegate : MacroConfigFileParserDelegate { var assignments = Array() var diagnostics = Array() + var locations = Array() var includeDirectivesCount = 0 @@ -834,9 +856,10 @@ private func TestMacroConfigFileParser(_ string: String, expectedAssignments: [A func endPreprocessorInclusion() { self.includeDirectivesCount += 1 } - func foundMacroValueAssignment(_ macroName: String, conditions: [(param: String, pattern: String)], value: String, parser: MacroConfigFileParser) { + func foundMacroValueAssignment(_ macroName: String, conditions: [(param: String, pattern: String)], value: String, path: Path, line: Int, startColumn: Int, endColumn: Int, parser: MacroConfigFileParser) { // print("\(parser.lineNumber): \(macroName)\(conditions.map({ "[\($0.param)=\($0.pattern)]" }).joinWithSeparator(""))=\(value)") assignments.append((macro: macroName, conditions: conditions, value: value)) + locations.append((macro: macroName, path: path, line: line, startColumn: startColumn, endColumn: endColumn)) } func handleDiagnostic(_ diagnostic: MacroConfigFileDiagnostic, parser: MacroConfigFileParser) { // print("\(parser.lineNumber): \(diagnostic)") @@ -857,6 +880,10 @@ private func TestMacroConfigFileParser(_ string: String, expectedAssignments: [A // Check the diagnostics that the delegate saw against the expected ones. #expect(delegate.diagnostics == expectedDiagnostics, "expected parse diagnostics \(expectedDiagnostics), but instead got \(delegate.diagnostics)", sourceLocation: sourceLocation) + if let expectedLocations { + #expect(delegate.locations == expectedLocations, "expected parse locations \(expectedLocations), but instead ogt \(delegate.locations)", sourceLocation: sourceLocation) + } + #expect(delegate.includeDirectivesCount == expectedIncludeDirectivesCount, "expected number of configs parsed to be \(expectedIncludeDirectivesCount), but instead got \(delegate.includeDirectivesCount)", sourceLocation: sourceLocation) } @@ -885,6 +912,14 @@ func ==(lhs: [DiagnosticInfo], rhs: [DiagnosticInfo]) -> Bool { return lhs.count == rhs.count && zip(lhs, rhs).filter({ return !($0.0 == $0.1) }).isEmpty } +func ==(lhs: LocationInfo, rhs: LocationInfo) -> Bool { + return (lhs.macro == rhs.macro) && (lhs.path == rhs.path) && (lhs.line == rhs.line) && (lhs.startColumn == rhs.startColumn) && (lhs.endColumn == rhs.endColumn) +} + +func ==(lhs: [LocationInfo], rhs: [LocationInfo]) -> Bool { + return lhs.count == rhs.count && zip(lhs, rhs).filter({ return !($0.0 == $0.1) }).isEmpty +} + /// Private helper function that parses a string representation as either a string or a string list (depending on the parameter), and checks the resulting parser delegate method call sequence and diagnostics (if applicable) against what’s expected. This is a private function that’s called by the two internal test functions TestMacroStringParsing() and TestMacroStringListParsing(). The original file name and line number are passed in so that Xcode diagnostics will refer to the call site. Each diagnostic is provided by the unit test as a tuple containing the level, kind, and associated range (expressed as start and end “distances”, in the manner of Int.Distance, into the original string). private func TestMacroParsing(_ string: String, asList: Bool, expectedCallLogEntries: [ParseDelegateCallLogEntry], expectedDiagnosticInfos: [(level: MacroExpressionDiagnostic.Level, kind: MacroExpressionDiagnostic.Kind, start: Int, end: Int)], sourceLocation: SourceLocation = #_sourceLocation) {