diff --git a/rusty_lr_buildscript/src/lib.rs b/rusty_lr_buildscript/src/lib.rs
index cce7bd05..fe1c5aef 100644
--- a/rusty_lr_buildscript/src/lib.rs
+++ b/rusty_lr_buildscript/src/lib.rs
@@ -293,19 +293,17 @@ impl Builder {
                 let diag =
                     match e {
                         ParseArgError::MacroLineParse { span, message } => {
-                            let range = span.byte_range();
-
                             Diagnostic::error()
                                 .with_message("Parse Failed")
                                 .with_labels(vec![
-                                    Label::primary(file_id, range).with_message("Error here")
+                                    Label::primary(file_id, span).with_message("Error here")
                                 ])
                                 .with_notes(vec![message])
                         }
 
                         _ => {
                             let message = e.short_message();
-                            let span = e.span().byte_range();
+                            let span = e.span();
                             Diagnostic::error().with_message(message).with_labels(vec![
                                 Label::primary(file_id, span).with_message("occured here"),
                             ])
@@ -321,13 +319,7 @@ impl Builder {
         };
 
         for error in &grammar_args.error_recovered {
-            let range = if let Some((first, last)) = error.span.pair {
-                let first_range = first.byte_range();
-                let last_range = last.byte_range();
-                first_range.start..last_range.end
-            } else {
-                0..1 // default range if span is not defined
-            };
+            let range = error.span.to_range();
             let diag = Diagnostic::error()
                 .with_message("Syntax error in grammar")
                 .with_labels(vec![
diff --git a/rusty_lr_parser/Cargo.toml b/rusty_lr_parser/Cargo.toml
index ed32aef8..7645e2b8 100644
--- a/rusty_lr_parser/Cargo.toml
+++ b/rusty_lr_parser/Cargo.toml
@@ -10,7 +10,7 @@ keywords = ["parser", "bison", "lr", "glr", "compiler"]
 categories = ["parsing"]
 
 [dependencies]
-proc-macro2 = "1.0.86"
+proc-macro2 = { version = "1.0.86", features = ["span-locations"] }
 quote = "1.0"
 rusty_lr_core = { version = "3.40.0", path = "../rusty_lr_core", features = [
     "builder",
diff --git a/rusty_lr_parser/src/error.rs b/rusty_lr_parser/src/error.rs
index b8b7ab51..e741dc1e 100644
--- a/rusty_lr_parser/src/error.rs
+++ b/rusty_lr_parser/src/error.rs
@@ -6,13 +6,17 @@ use proc_macro2::TokenStream;
 use quote::quote_spanned;
 
 use crate::parser::args::IdentOrLiteral;
+use crate::parser::span_pair::byte_range_to_span;
 
 /// failed to feed() the token
 #[non_exhaustive]
 #[derive(Debug)]
 pub enum ParseArgError {
-    /// feed() failed
-    MacroLineParse { span: Span, message: String },
+    /// feed() failed; `span` is the byte range `[start, end)` in the source
+    MacroLineParse {
+        span: std::ops::Range<usize>,
+        message: String,
+    },
 }
 
 #[non_exhaustive]
@@ -195,17 +199,18 @@ impl ArgError {
 #[allow(unused)]
 impl ParseArgError {
     pub fn to_compile_error(&self) -> TokenStream {
-        let span = self.span();
         let message = self.short_message();
+        let span = byte_range_to_span(self.span());
         quote_spanned! {
             span=>
             compile_error!(#message);
         }
     }
 
-    pub fn span(&self) -> Span {
+    /// Returns the byte range `[start, end)` of the error location in the source.
+    pub fn span(&self) -> std::ops::Range<usize> {
         match self {
-            ParseArgError::MacroLineParse { span, message } => *span,
+            ParseArgError::MacroLineParse { span, message } => span.clone(),
         }
     }
 
diff --git a/rusty_lr_parser/src/grammar.rs b/rusty_lr_parser/src/grammar.rs
index 8b602ced..20ba62e3 100644
--- a/rusty_lr_parser/src/grammar.rs
+++ b/rusty_lr_parser/src/grammar.rs
@@ -185,7 +185,7 @@ impl Grammar {
             Ok(_) => {}
             Err(err) => {
                 let message = err.to_string();
-                let span = err.location().unwrap().pair.unwrap().0;
+                let span = err.location().map(|loc| loc.to_range()).unwrap_or(0..0);
                 return Err(ParseArgError::MacroLineParse { span, message });
             }
         }
@@ -193,8 +193,7 @@ impl Grammar {
             Ok(_) => {}
             Err(err) => {
                 let message = err.to_string();
-                let span = Span::call_site();
-                return Err(ParseArgError::MacroLineParse { span, message });
+                return Err(ParseArgError::MacroLineParse { span: 0..0, message });
             }
         }
 
diff --git a/rusty_lr_parser/src/parser/args.rs b/rusty_lr_parser/src/parser/args.rs
index c16327a7..db1e491a 100644
--- a/rusty_lr_parser/src/parser/args.rs
+++ b/rusty_lr_parser/src/parser/args.rs
@@ -371,8 +371,8 @@ impl PatternArgs {
                 }
             }
             PatternArgs::Sep(_, _, _, span) => {
-                let (first, last) = span.pair.unwrap();
-                Err(ParseError::OnlyTerminalSet(first, last))
+                let s = span.span();
+                Err(ParseError::OnlyTerminalSet(s, s))
             }
         }
     }
@@ -400,7 +400,10 @@ impl PatternArgs {
             PatternArgs::Minus(base, terminal_set) => {
                 (base.span_pair().0, terminal_set.span_pair().1)
             }
-            PatternArgs::Sep(_, _, _, span) => span.pair.unwrap(),
+            PatternArgs::Sep(_, _, _, span) => {
+                let s = span.span();
+                (s, s)
+            }
         }
     }
 
diff --git a/rusty_lr_parser/src/parser/span_pair.rs b/rusty_lr_parser/src/parser/span_pair.rs
index fdaa59b8..af573b68 100644
--- a/rusty_lr_parser/src/parser/span_pair.rs
+++ b/rusty_lr_parser/src/parser/span_pair.rs
@@ -1,48 +1,63 @@
 use proc_macro2::Span;
 
+/// Converts a byte range `[start, end)` into a `proc_macro2::Span`.
+/// Currently, byte offsets cannot be mapped back precisely, so this returns `Span::call_site()`.
+pub fn byte_range_to_span(_range: std::ops::Range<usize>) -> Span {
+    Span::call_site()
+}
+
 /// type for %location for each token
-/// since `Span::join()` is only for nightly,
-/// we collect the first and last span pair of the token in the parsing tree.
+/// stores byte offset range [start, end) in the source file.
 #[derive(Clone, Debug, Copy)]
 pub struct SpanPair {
-    /// `None` if this is a zero-length span
-    pub pair: Option<(Span, Span)>,
+    /// byte range `[start, end)` of the token span.
+    /// zero-length spans are represented with equal values `(pos, pos)`.
+    pub pair: (usize, usize),
 }
 impl Default for SpanPair {
     fn default() -> Self {
-        SpanPair { pair: None }
+        SpanPair { pair: (0, 0) }
     }
 }
 impl SpanPair {
     pub fn new_single(span: Span) -> Self {
+        let range = span.byte_range();
         SpanPair {
-            pair: Some((span, span)),
+            pair: (range.start, range.end),
         }
     }
+    /// Returns the byte range `[start, end)` of this span.
+    pub fn to_range(&self) -> std::ops::Range<usize> {
+        let (s, e) = self.pair;
+        s..e
+    }
+    /// Returns a `proc_macro2::Span` for use in proc-macro error reporting.
+    /// Since byte offsets cannot be converted back to `Span`, this falls back to `byte_range_to_span`.
     pub fn span(&self) -> Span {
-        self.pair
-            .as_ref()
-            .map_or(Span::call_site(), |(first, last)| {
-                if let Some(joined) = first.join(*last) {
-                    joined
-                } else {
-                    *first
-                }
-            })
+        byte_range_to_span(self.to_range())
     }
 }
 impl rusty_lr_core::Location for SpanPair {
-    fn new<'a>(stack: impl Iterator<Item = &'a Self> + Clone, len: usize) -> Self
+    fn new<'a>(mut stack: impl Iterator<Item = &'a Self> + Clone, len: usize) -> Self
     where
         Self: 'a,
     {
-        let mut take = stack.take(len).filter_map(|x| x.pair);
-        let pair = if let Some(last) = take.next() {
-            let first = take.last().unwrap_or(last);
-
-            Some((first.0, last.1))
+        if len == 0 {
+            // zero-length: point to position after the most recent token
+            if let Some(after_pos) = stack.next() {
+                let (_, e) = after_pos.pair;
+                return SpanPair { pair: (e, e) };
+            }
+            return SpanPair::default();
+        }
+        // The iterator yields items most-recent-first, so the first item is the end span
+        // and the last item taken is the start span.
+        let mut take = stack.take(len).map(|x| x.pair);
+        let pair = if let Some(end_span) = take.next() {
+            let start_span = take.last().unwrap_or(end_span);
+            (start_span.0, end_span.1)
         } else {
-            None
+            (0, 0)
         };
         SpanPair { pair }
     }
