Skip to content

Commit 2f82130

Browse files
Add SourceMap::resolve_span for resolving global span locations (#2559)
* Add resolve_span * Make doc shorter * fmt * Shorten doc * Simplify downcasting in tests
1 parent a7c1c8a commit 2f82130

4 files changed

Lines changed: 118 additions & 14 deletions

File tree

crates/wit-parser/src/ast.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,6 +1946,29 @@ impl SourceMap {
19461946
return msg;
19471947
}
19481948

1949+
/// Resolves a global `span` to a location within a single source file.
1950+
///
1951+
/// Returns the path the source was registered with and the span's byte
1952+
/// range (UTF-8) relative to that file's contents. Returns `None` if
1953+
/// `span` is unknown or does not fall within any source in this map.
1954+
pub fn resolve_span(&self, span: Span) -> Option<SpanLocation<'_>> {
1955+
if !span.is_known() || span.start() >= self.offset {
1956+
return None;
1957+
}
1958+
let src = self.source_for_offset(span.start());
1959+
// Exclude the synthetic `\n` appended by `push_str` so that spans
1960+
// pointing at eof resolve to an empty range at the end of the file.
1961+
let len = src.contents.len() - 1;
1962+
let start = src.to_relative_offset(span.start()).min(len);
1963+
let end = usize::try_from(span.end().saturating_sub(src.offset))
1964+
.unwrap()
1965+
.clamp(start, len);
1966+
Some(SpanLocation {
1967+
path: &src.path,
1968+
range: start..end,
1969+
})
1970+
}
1971+
19491972
/// Renders a span as a human-readable location string (e.g., "file.wit:10:5").
19501973
pub fn render_location(&self, span: Span) -> String {
19511974
if !span.is_known() {
@@ -1983,6 +2006,15 @@ impl SourceMap {
19832006
}
19842007
}
19852008

2009+
/// A [`Span`] resolved to a location within a single source file.
2010+
#[derive(Clone, Debug, PartialEq, Eq)]
2011+
pub struct SpanLocation<'a> {
2012+
/// The path of the source, as it was registered with the [`SourceMap`].
2013+
pub path: &'a str,
2014+
/// The byte range (UTF-8) within the file's contents.
2015+
pub range: core::ops::Range<usize>,
2016+
}
2017+
19862018
impl Source {
19872019
fn to_relative_offset(&self, offset: u32) -> usize {
19882020
usize::try_from(offset - self.offset).unwrap()

crates/wit-parser/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ pub mod abi;
4444
mod ast;
4545
pub use ast::error::*;
4646
pub use ast::lex::Span;
47-
pub use ast::{ItemName, SourceMap};
47+
pub use ast::{ItemName, SourceMap, SpanLocation};
4848
pub use ast::{ParsedUsePath, parse_use_path};
4949
mod sizealign;
5050
pub use sizealign::*;

crates/wit-parser/tests/downcast.rs

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,14 @@ use wit_parser::{
66
ParseError, ParseErrorKind, Resolve, ResolveError, ResolveErrorKind, UnresolvedPackageGroup,
77
};
88

9-
fn outer_resolve_error(err: &anyhow::Error) -> &ResolveError {
9+
fn resolve_error(err: &anyhow::Error) -> &ResolveError {
1010
err.downcast_ref::<ResolveError>()
11-
.expect("expected the outermost error to be a ResolveError")
11+
.expect("expected error to downcast to ResolveError")
1212
}
1313

14-
fn parse_error_in_chain(err: &anyhow::Error) -> &ParseError {
15-
err.chain()
16-
.find_map(|layer| layer.downcast_ref::<ParseError>())
17-
.expect("expected a ParseError somewhere in the error chain")
14+
fn parse_error(err: &anyhow::Error) -> &ParseError {
15+
err.downcast_ref::<ParseError>()
16+
.expect("expected error to downcast to ParseError")
1817
}
1918

2019
#[test]
@@ -26,7 +25,7 @@ fn push_str_returns_downcastable_error() {
2625
"package foo:foo;\nworld w { import some:dependency/iface; }",
2726
)
2827
.expect_err("expected resolve to fail");
29-
let re = outer_resolve_error(&err);
28+
let re = resolve_error(&err);
3029
assert!(
3130
matches!(re.kind(), ResolveErrorKind::PackageNotFound { .. }),
3231
"expected PackageNotFound, got: {:?}",
@@ -40,7 +39,7 @@ fn push_file_returns_downcastable_error() {
4039
let err = resolve
4140
.push_file("tests/ui/parse-fail/unresolved-interface4.wit")
4241
.expect_err("expected resolve to fail");
43-
let re = outer_resolve_error(&err);
42+
let re = resolve_error(&err);
4443
assert!(
4544
matches!(re.kind(), ResolveErrorKind::PackageNotFound { .. }),
4645
"expected PackageNotFound, got: {:?}",
@@ -54,7 +53,7 @@ fn push_dir_returns_downcastable_error() {
5453
let err = resolve
5554
.push_dir("tests/ui/parse-fail/bad-pkg6")
5655
.expect_err("expected resolve to fail");
57-
let re = outer_resolve_error(&err);
56+
let re = resolve_error(&err);
5857
assert!(
5958
matches!(re.kind(), ResolveErrorKind::PackageNotFound { .. }),
6059
"expected PackageNotFound, got: {:?}",
@@ -68,7 +67,7 @@ fn push_str_parse_error_is_downcastable() {
6867
let err = resolve
6968
.push_str("test.wit", "package foo:foo;\nworld w { not-a-keyword }")
7069
.expect_err("expected parse to fail");
71-
let pe = parse_error_in_chain(&err);
70+
let pe = parse_error(&err);
7271
// Spans must be valid against `resolve.source_map` after the failure-path
7372
// merge, so highlighting against it should not panic and should include
7473
// the file and line.
@@ -99,7 +98,7 @@ fn parse_error_spans_are_adjusted_after_earlier_pushes() {
9998
"package foo:second;\nworld w { not-a-keyword }",
10099
)
101100
.expect_err("expected parse to fail");
102-
let pe = parse_error_in_chain(&err);
101+
let pe = parse_error(&err);
103102
// The failing package's sources land at a nonzero offset in
104103
// `resolve.source_map` because `first.wit` was merged before it, so this
105104
// only points at the right file and line if the error's spans were
@@ -121,14 +120,14 @@ fn push_file_parse_error_is_downcastable() {
121120
let err = resolve
122121
.push_file("tests/ui/parse-fail/bad-function.wit")
123122
.expect_err("expected parse to fail");
124-
parse_error_in_chain(&err);
123+
parse_error(&err);
125124
}
126125

127126
#[test]
128127
fn parse_dir_parse_error_is_downcastable() {
129128
let err = UnresolvedPackageGroup::parse_dir("tests/ui/parse-fail/multi-file-missing-delimiter")
130129
.expect_err("expected parse to fail");
131-
parse_error_in_chain(&err);
130+
parse_error(&err);
132131
// The source map is discarded on failure, but the rendered location must
133132
// be preserved in the error's message.
134133
assert!(

crates/wit-parser/tests/spans.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
//! Tests for `SourceMap::resolve_span`, which resolves a global `Span` to a
2+
//! file path and file-local byte range.
3+
4+
use wit_parser::{ParseError, Resolve, SourceMap, UnresolvedPackageGroup};
5+
6+
#[test]
7+
fn resolve_span_in_single_file() {
8+
let contents = "package foo:foo;\nworld w { not-a-keyword }";
9+
let (map, err) =
10+
UnresolvedPackageGroup::parse("test.wit", contents).expect_err("expected parse to fail");
11+
let loc = map
12+
.resolve_span(err.kind().span())
13+
.expect("span should resolve");
14+
assert_eq!(loc.path, "test.wit");
15+
assert_eq!(&contents[loc.range.clone()], "not-a-keyword");
16+
}
17+
18+
#[test]
19+
fn resolve_span_in_second_file() {
20+
let mut map = SourceMap::default();
21+
map.push_str("a.wit", "package foo:foo;\ninterface a {}\n");
22+
let b = "interface b { type t = nonexistent; }";
23+
map.push_str("b.wit", b);
24+
let (map, err) = map.parse().expect_err("expected parse to fail");
25+
let loc = map
26+
.resolve_span(err.kind().span())
27+
.expect("span should resolve");
28+
assert_eq!(loc.path, "b.wit");
29+
assert_eq!(&b[loc.range.clone()], "nonexistent");
30+
}
31+
32+
#[test]
33+
fn resolve_span_at_eof() {
34+
let contents = "package foo:foo;\nworld w {";
35+
let (map, err) =
36+
UnresolvedPackageGroup::parse("test.wit", contents).expect_err("expected parse to fail");
37+
let loc = map
38+
.resolve_span(err.kind().span())
39+
.expect("span should resolve");
40+
assert_eq!(loc.path, "test.wit");
41+
// The source map appends a synthetic newline internally and eof spans
42+
// point at it; they must resolve to an empty range at the real length.
43+
assert_eq!(loc.range, contents.len()..contents.len());
44+
}
45+
46+
#[test]
47+
fn resolve_span_not_in_map() {
48+
let contents = "package foo:foo;\nworld w { not-a-keyword }";
49+
let (_, err) =
50+
UnresolvedPackageGroup::parse("test.wit", contents).expect_err("expected parse to fail");
51+
let other = SourceMap::default();
52+
assert!(other.resolve_span(err.kind().span()).is_none());
53+
}
54+
55+
#[test]
56+
fn resolve_span_against_resolve_source_map() {
57+
let mut resolve = Resolve::new();
58+
let contents = "package foo:foo;\nworld w { not-a-keyword }";
59+
let err = resolve
60+
.push_str("test.wit", contents)
61+
.expect_err("expected parse to fail");
62+
let pe = err
63+
.downcast_ref::<ParseError>()
64+
.expect("expected error to downcast to ParseError");
65+
// The failure-path merge keeps spans valid against `resolve.source_map`,
66+
// so resolution against it must find the right file and range.
67+
let loc = resolve
68+
.source_map
69+
.resolve_span(pe.kind().span())
70+
.expect("span should resolve");
71+
assert_eq!(loc.path, "test.wit");
72+
assert_eq!(&contents[loc.range.clone()], "not-a-keyword");
73+
}

0 commit comments

Comments
 (0)