diff --git a/Sources/Parsing/ParserPrinters/Indexed.swift b/Sources/Parsing/ParserPrinters/Indexed.swift
new file mode 100644
index 0000000000..70f439033d
--- /dev/null
+++ b/Sources/Parsing/ParserPrinters/Indexed.swift
@@ -0,0 +1,31 @@
+extension Parsers {
+ /// A parser of a collection that returns a base parser's output as well as the index range of the
+ /// parsed input.
+ public struct Indexed: Parser
+ where Base: Parser, Base.Input: Collection {
+ public let base: Base
+
+ @inlinable
+ init(_ base: Base) {
+ self.base = base
+ }
+
+ @inlinable
+ public func parse(
+ _ input: inout Base.Input
+ ) throws -> (output: Base.Output, indices: Range) {
+ let startIndex = input.startIndex
+ let output = try base.parse(&input)
+ return (output, startIndex.. Parsers.Indexed {
+ Parsers.Indexed(self)
+ }
+}
diff --git a/Tests/ParsingTests/IndexedTests.swift b/Tests/ParsingTests/IndexedTests.swift
new file mode 100644
index 0000000000..270dd3d294
--- /dev/null
+++ b/Tests/ParsingTests/IndexedTests.swift
@@ -0,0 +1,46 @@
+import Parsing
+import XCTest
+
+private struct Output: Equatable {
+ var username: Substring
+ var range: Range
+}
+
+final class IndexedTests: XCTestCase {
+ func testStringHappyPath() throws {
+ let input = "Hi @BlobJr; please call @BlobSr when you get a chance. Thanks."
+ let parser: some Parser = Many {
+ Skip {
+ PrefixUpTo("@".utf8)
+ }
+
+ From(.substring) {
+ Parse {
+ "@"
+
+ Prefix(while: { $0.isLetter || $0.isNumber })
+ }
+ .indexed()
+ }
+ .map(Output.init)
+ } terminator: {
+ Rest()
+ }
+ let usernames = try parser.parse(input)
+ XCTAssertEqual(
+ usernames,
+ [
+ Output(
+ username: "BlobJr",
+ range: input
+ .index(input.startIndex, offsetBy: 3)..