Skip to main content

neo_devpack_solidity/frontend/
frontend_parse.rs

1/// Parse Solidity source into [`ContractIR`] values.
2pub fn parse_source(source: &str) -> Result<Vec<ContractIR>, FrontendError> {
3    let (source_unit, comments) = parse_solidity_guarded(source)
4        .map_err(|diags| FrontendError::ParseDiagnostics(collect_parse_diagnostics(source, &diags)))?;
5
6    // Build a map of end positions to preceding doc comments
7    let comment_map = build_comment_map(&comments, source);
8
9    let mut contracts = Vec::new();
10    // Collect file-level `type X is Y` definitions so they can be injected into all contracts.
11    let mut file_level_type_aliases: std::collections::HashMap<String, String> =
12        std::collections::HashMap::new();
13    let mut file_level_structs: Vec<StructIR> = Vec::new();
14    let mut file_level_enums: Vec<EnumIR> = Vec::new();
15    // File-scope custom `error` declarations (Solidity 0.8.4+). Merged into
16    // every contract so revert-site lowering can resolve the declared
17    // signature regardless of where the error was declared.
18    let mut file_level_errors: Vec<ErrorIR> = Vec::new();
19    // Task #187 — collect file-scope free functions (Solidity 0.7+). A free
20    // function like `function helper(uint a, uint b) pure returns (uint) { ... }`
21    // declared outside any contract is conceptually internal to every contract
22    // in the source unit; merging it into each primary contract's function
23    // table lets call-site dispatch (`ctx.function_names.contains(...)`)
24    // resolve the reference as a regular internal call.
25    let mut file_level_free_functions: Vec<FunctionIR> = Vec::new();
26    // Task #188 — Solidity 0.8.13+ file-level `using { L.f1, L.f2 } for T;`
27    // (and `using L for T global;`) attach directives apply to every contract
28    // declared in the same source unit. Collect them here and merge into each
29    // converted ContractIR below, symmetric with the file-level type-alias /
30    // struct / enum / free-function injection passes. Without this, the IR
31    // lowering stage only sees contract-scope `using` directives and the
32    // member-style call resolver hard-errors on `x.double()` for any
33    // attachment declared at file scope.
34    let mut file_level_usings: Vec<Using> = Vec::new();
35
36    // Track the declared pragma's minimum Solidity version so we can reject
37    // features that were introduced later (solc-compatible behavior).
38    let mut pragma_min_version: Option<Version> = None;
39
40    for part in source_unit.0 {
41        match part {
42            SourceUnitPart::PragmaDirective(pragma) => {
43                if let Some(min) = enforce_supported_pragma(&pragma)? {
44                    // The combined source unit's effective minimum compiler
45                    // version is the *intersection* of every file's pragma
46                    // range. Since each file's pragma gives a lower bound on
47                    // the version that file accepts, the chosen compiler
48                    // version must be `>= max(file_mins)`. Earlier versions
49                    // tracked the MIN here, which incorrectly lowered the
50                    // effective version when one imported file declared
51                    // `>=0.4.16` (a broad lower bound used by ENS / Aave / some
52                    // OZ utility files); that caused legitimate uses of
53                    // `string.concat` / `bytes.concat` in the entry contract
54                    // to fail the feature-version gate.
55                    pragma_min_version = match pragma_min_version {
56                        Some(existing) if existing >= min => Some(existing),
57                        _ => Some(min),
58                    };
59                }
60            }
61            SourceUnitPart::ContractDefinition(contract) => {
62                contracts.push(convert_contract(*contract, &comment_map));
63            }
64            SourceUnitPart::TypeDefinition(td) => {
65                let underlying = format!("{}", td.ty);
66                file_level_type_aliases.insert(td.name.name, underlying);
67            }
68            SourceUnitPart::StructDefinition(def) => {
69                file_level_structs.push(convert_struct(*def));
70            }
71            SourceUnitPart::EnumDefinition(def) => {
72                file_level_enums.push(convert_enum(*def));
73            }
74            SourceUnitPart::ErrorDefinition(def) => {
75                file_level_errors.push(convert_error(*def));
76            }
77            SourceUnitPart::FunctionDefinition(def) => {
78                // Task #187 — file-scope free function. Free functions are
79                // implicitly internal (Solidity rejects `public`/`external`
80                // at file scope). Normalize visibility to `Internal` so the
81                // merged function behaves like any other internal helper in
82                // the consuming contract, and mark the type as `Function`
83                // regardless of what solang-parser surfaces.
84                let mut fn_ir = convert_function(*def, &comment_map);
85                fn_ir.visibility = VisibilityKind::Internal;
86                fn_ir.ty = FunctionTy::Function;
87                file_level_free_functions.push(fn_ir);
88            }
89            SourceUnitPart::Using(using) => {
90                // Task #188 — capture file-level `using` directives; merged
91                // into each contract below once all parts have been parsed.
92                file_level_usings.push(*using);
93            }
94            // L-FE1 note — the catch-all silently drops any unrecognized
95            // SourceUnitPart variant (e.g. a future Solidity grammar extension,
96            // or a free function / file-level event). Today every variant the
97            // solang-parser emits for supported source is handled above, so
98            // this is unreachable in practice; if it ever fires, the contract
99            // will compile to nothing with no feedback. Surfacing a diagnostic
100            // here requires threading a diagnostics sink through this
101            // function (tracked as a follow-up polish item).
102            _ => {}
103        }
104    }
105
106    // Enforce per-feature pragma gates (solc emits a hard error when a feature
107    // is used outside its declared minimum version). POC: `string.concat` /
108    // `bytes.concat`. See `FEATURE_*_MIN` constants for the registry.
109    enforce_feature_version_gates(source, pragma_min_version)?;
110
111    // Inject file-level type aliases into every contract in the file.
112    if !file_level_type_aliases.is_empty() {
113        for contract in &mut contracts {
114            for (name, underlying) in &file_level_type_aliases {
115                contract
116                    .type_aliases
117                    .entry(name.clone())
118                    .or_insert_with(|| underlying.clone());
119            }
120        }
121    }
122
123    if !file_level_structs.is_empty() {
124        for contract in &mut contracts {
125            for file_struct in &file_level_structs {
126                if !contract
127                    .structs
128                    .iter()
129                    .any(|existing| existing.name == file_struct.name)
130                {
131                    contract.structs.push(file_struct.clone());
132                }
133            }
134        }
135    }
136
137    if !file_level_enums.is_empty() {
138        for contract in &mut contracts {
139            for file_enum in &file_level_enums {
140                if !contract
141                    .enums
142                    .iter()
143                    .any(|existing| existing.name == file_enum.name)
144                {
145                    contract.enums.push(file_enum.clone());
146                }
147            }
148        }
149    }
150
151    // Inject file-level custom errors into every contract in the file.
152    // Contract-scope declarations shadow same-named file-scope ones.
153    if !file_level_errors.is_empty() {
154        for contract in &mut contracts {
155            for file_error in &file_level_errors {
156                if !contract
157                    .errors
158                    .iter()
159                    .any(|existing| existing.name == file_error.name)
160                {
161                    contract.errors.push(file_error.clone());
162                }
163            }
164        }
165    }
166
167    // Task #187 — inject file-scope free functions into every contract in the
168    // source unit. Mirrors how the library-merge pass in `analyse_all_sources`
169    // pulls sibling library bodies into primary contracts so the IR lowering
170    // stage (`function_names` symbol table) can dispatch free-function calls
171    // as regular internal calls instead of falling through to the
172    // unresolved-call compatibility path that silently drops arguments and
173    // pushes a zero return value. Contracts where a same-named method already
174    // exists keep their own definition (contract-scope wins).
175    if !file_level_free_functions.is_empty() {
176        for contract in &mut contracts {
177            for free_fn in &file_level_free_functions {
178                if !contract
179                    .functions
180                    .iter()
181                    .any(|existing| existing.name == free_fn.name)
182                {
183                    contract.functions.push(free_fn.clone());
184                }
185            }
186        }
187    }
188
189    // Task #188 — merge every file-level `using` directive into each contract
190    // in the source unit. The IR-lowering stage consumes `ContractIR`'s
191    // `using_directives` / `using_for_libraries` / `has_using_function_list`
192    // fields to build the `using_target_types`, `using_function_list_targets`,
193    // and `using_function_list_scope_targets` symbol tables that drive
194    // `ctx.has_using_directives()` and the member-style call resolver. Without
195    // this merge, the file-level form `using { L.f1, L.f2 } for T;` is
196    // completely invisible to lowering (both library-form `using L for T;`
197    // and function-list form are affected). Libraries don't participate in
198    // `using`-for dispatch, so skip them here — mirroring the
199    // `normalize_library_for_neo` treatment downstream.
200    if !file_level_usings.is_empty() {
201        for contract in &mut contracts {
202            if matches!(contract.kind, ContractKind::Library) {
203                continue;
204            }
205            for using in &file_level_usings {
206                apply_file_level_using(contract, using);
207            }
208        }
209    }
210
211    Ok(contracts)
212}
213
214fn enforce_supported_pragma(
215    pragma: &solang_parser::pt::PragmaDirective,
216) -> Result<Option<Version>, FrontendError> {
217    use solang_parser::pt::PragmaDirective;
218
219    let PragmaDirective::Version(_, ident, comparators) = pragma else {
220        return Ok(None);
221    };
222
223    if ident.name != "solidity" {
224        return Ok(None);
225    }
226
227    let spec = comparators
228        .iter()
229        .map(std::string::ToString::to_string)
230        .collect::<Vec<_>>()
231        .join(" ");
232
233    // Compiler compatibility targets mainstream modern Solidity ranges used by
234    // upstream protocols. We accept pragmas that intersect 0.5.x through 0.8.x.
235    if pragma_supports_neo_devpack_solidity(spec.as_str()) {
236        Ok(pragma_min_version(spec.as_str()))
237    } else {
238        Err(FrontendError::UnsupportedVersion(spec))
239    }
240}
241
242/// Compute the pragma's lowest-allowed concrete Solidity version.
243///
244/// Used to enforce per-feature version gates (e.g. `string.concat` requires
245/// `>= 0.8.12`). When the pragma admits multiple OR-branches, we pick the
246/// smallest lower bound since any of those versions may be used at compile
247/// time. Returns `None` for unbounded or unparseable ranges.
248fn pragma_min_version(spec: &str) -> Option<Version> {
249    let normalized = spec.replace(' ', "").to_lowercase();
250    if normalized.is_empty() {
251        return None;
252    }
253
254    let mut best: Option<Version> = None;
255    for branch in normalized.split("||") {
256        let Some(v) = branch_min_version(branch) else {
257            continue;
258        };
259        best = match best {
260            Some(existing) if existing <= v => Some(existing),
261            _ => Some(v),
262        };
263    }
264    best
265}
266
267fn branch_min_version(branch: &str) -> Option<Version> {
268    let comparators = split_comparators(branch);
269    let mut lower: Option<Version> = None;
270    let mut update = |candidate: Version| {
271        lower = match lower {
272            Some(existing) if existing >= candidate => Some(existing),
273            _ => Some(candidate),
274        };
275    };
276
277    for comparator in comparators {
278        if comparator == "*" {
279            continue;
280        }
281        if let Some((start, _)) = parse_hyphen_range(&comparator) {
282            update(start);
283            continue;
284        }
285        if let Some((version, _)) = parse_caret(&comparator) {
286            update(version);
287            continue;
288        }
289        if let Some(version) = parse_tilde(&comparator) {
290            update(version);
291            continue;
292        }
293        if let Some((op, version)) = parse_operator_version(&comparator) {
294            match op {
295                ComparatorOp::Greater => update(next_patch(version)),
296                ComparatorOp::GreaterEq | ComparatorOp::Exact => update(version),
297                _ => {}
298            }
299            continue;
300        }
301        if let Some(version) = parse_plain_version(&comparator) {
302            update(version);
303        }
304    }
305    lower
306}
307
308/// Feature registry: features that require a minimum Solidity version.
309///
310/// POC covers `string.concat`/`bytes.concat` (both introduced in 0.8.12 /
311/// 0.8.4 respectively). Extend this as more features are gated.
312const FEATURE_STRING_CONCAT_MIN: Version = Version {
313    major: 0,
314    minor: 8,
315    patch: 12,
316};
317const FEATURE_BYTES_CONCAT_MIN: Version = Version {
318    major: 0,
319    minor: 8,
320    patch: 4,
321};
322
323/// Reject feature uses that predate the pragma's declared minimum version.
324///
325/// Scans `source` for `string.concat(` / `bytes.concat(` tokens (outside of
326/// comments and string literals) and errors when `pragma_min` is below the
327/// feature's introduction version. Matches solc's "feature unavailable in
328/// declared pragma range" behavior for the most common gap.
329fn enforce_feature_version_gates(
330    source: &str,
331    pragma_min: Option<Version>,
332) -> Result<(), FrontendError> {
333    let Some(min) = pragma_min else {
334        return Ok(());
335    };
336
337    let stripped = strip_comments_and_strings(source);
338
339    if min < FEATURE_STRING_CONCAT_MIN && contains_builtin_call(&stripped, "string.concat(") {
340        return Err(FrontendError::Parse(format!(
341            "feature `string.concat` requires pragma >= 0.8.12; declared pragma allows {}.{}.{}",
342            min.major, min.minor, min.patch
343        )));
344    }
345    if min < FEATURE_BYTES_CONCAT_MIN && contains_builtin_call(&stripped, "bytes.concat(") {
346        return Err(FrontendError::Parse(format!(
347            "feature `bytes.concat` requires pragma >= 0.8.4; declared pragma allows {}.{}.{}",
348            min.major, min.minor, min.patch
349        )));
350    }
351    Ok(())
352}
353
354/// True when `needle` (e.g. `"string.concat("`) appears as a real builtin call
355/// rather than as a suffix of a larger identifier — i.e. the character
356/// immediately before it is not part of an identifier (`a-zA-Z0-9_`). Without
357/// this a user variable like `myString.concat(...)` would falsely trip the
358/// pragma-feature gate.
359fn contains_builtin_call(haystack: &str, needle: &str) -> bool {
360    let bytes = haystack.as_bytes();
361    let mut start = 0usize;
362    while let Some(pos) = haystack[start..].find(needle) {
363        let abs = start + pos;
364        let boundary_ok = abs == 0
365            || {
366                let prev = bytes[abs - 1];
367                !(prev.is_ascii_alphanumeric() || prev == b'_')
368            };
369        if boundary_ok {
370            return true;
371        }
372        start = abs + 1;
373    }
374    false
375}
376
377/// Lightweight lexer-aware strip: replaces string-literal and comment bodies
378/// with spaces so they cannot produce false positives for feature scans.
379/// Handles `//` line comments, `/* */` block comments, `"..."` and `'...'`
380/// string literals with backslash escapes. Adequate for identifier-level
381/// feature probing (not a full Solidity lexer).
382fn strip_comments_and_strings(source: &str) -> String {
383    let bytes = source.as_bytes();
384    let mut out = String::with_capacity(source.len());
385    let mut i = 0;
386    while i < bytes.len() {
387        let c = bytes[i];
388        if c == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
389            while i < bytes.len() && bytes[i] != b'\n' {
390                out.push(' ');
391                i += 1;
392            }
393            continue;
394        }
395        if c == b'/' && i + 1 < bytes.len() && bytes[i + 1] == b'*' {
396            out.push_str("  ");
397            i += 2;
398            while i + 1 < bytes.len() && !(bytes[i] == b'*' && bytes[i + 1] == b'/') {
399                out.push(' ');
400                i += 1;
401            }
402            if i + 1 < bytes.len() {
403                out.push_str("  ");
404                i += 2;
405            }
406            continue;
407        }
408        if c == b'"' || c == b'\'' {
409            let quote = c;
410            out.push(' ');
411            i += 1;
412            while i < bytes.len() && bytes[i] != quote {
413                if bytes[i] == b'\\' && i + 1 < bytes.len() {
414                    out.push_str("  ");
415                    i += 2;
416                    continue;
417                }
418                out.push(' ');
419                i += 1;
420            }
421            if i < bytes.len() {
422                out.push(' ');
423                i += 1;
424            }
425            continue;
426        }
427        out.push(c as char);
428        i += 1;
429    }
430    out
431}
432
433fn pragma_supports_neo_devpack_solidity(spec: &str) -> bool {
434    let normalized = spec.replace(' ', "").to_lowercase();
435
436    if normalized.is_empty() {
437        return true;
438    }
439
440    // Accept if any OR-branch can include a supported compiler range.
441    normalized
442        .split("||")
443        .any(branch_supports_neo_devpack_solidity)
444}
445
446fn branch_supports_neo_devpack_solidity(branch: &str) -> bool {
447    if branch.is_empty() {
448        return false;
449    }
450
451    let comparators = split_comparators(branch);
452    if comparators.is_empty() {
453        return false;
454    }
455
456    let mut lower = Bound::Unbounded;
457    let mut upper = Bound::Unbounded;
458
459    for comparator in comparators {
460        if comparator == "*" {
461            continue;
462        }
463
464        if let Some((start, end)) = parse_hyphen_range(&comparator) {
465            lower = lower.max(Bound::Inclusive(start));
466            upper = upper.min(Bound::Inclusive(end));
467            continue;
468        }
469
470        if let Some((version, level)) = parse_caret(&comparator) {
471            let upper_version = match level {
472                0 => Version {
473                    major: version.major.saturating_add(1),
474                    minor: 0,
475                    patch: 0,
476                },
477                _ => Version {
478                    major: version.major,
479                    minor: version.minor.saturating_add(1),
480                    patch: 0,
481                },
482            };
483            lower = lower.max(Bound::Inclusive(version));
484            upper = upper.min(Bound::Exclusive(upper_version));
485            continue;
486        }
487
488        if let Some(version) = parse_tilde(&comparator) {
489            let upper_version = Version {
490                major: version.major,
491                minor: version.minor.saturating_add(1),
492                patch: 0,
493            };
494            lower = lower.max(Bound::Inclusive(version));
495            upper = upper.min(Bound::Exclusive(upper_version));
496            continue;
497        }
498
499        if let Some((op, version)) = parse_operator_version(&comparator) {
500            match op {
501                ComparatorOp::Greater => lower = lower.max(Bound::Exclusive(version)),
502                ComparatorOp::GreaterEq => lower = lower.max(Bound::Inclusive(version)),
503                ComparatorOp::Less => upper = upper.min(Bound::Exclusive(version)),
504                ComparatorOp::LessEq => upper = upper.min(Bound::Inclusive(version)),
505                ComparatorOp::Exact => {
506                    lower = lower.max(Bound::Inclusive(version));
507                    upper = upper.min(Bound::Inclusive(version));
508                }
509            }
510            continue;
511        }
512
513        if let Some(version) = parse_plain_version(&comparator) {
514            lower = lower.max(Bound::Inclusive(version));
515            upper = upper.min(Bound::Inclusive(version));
516            continue;
517        }
518
519        // Unknown comparator format: reject conservatively.
520        return false;
521    }
522
523    intersects_supported_neo_range(lower, upper)
524}
525
526#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
527struct Version {
528    major: u64,
529    minor: u64,
530    patch: u64,
531}
532
533#[derive(Clone, Copy, Debug)]
534enum Bound {
535    Unbounded,
536    Inclusive(Version),
537    Exclusive(Version),
538}
539
540impl Bound {
541    fn max(self, other: Self) -> Self {
542        use Bound::{Exclusive, Inclusive, Unbounded};
543
544        match (self, other) {
545            (Unbounded, x) | (x, Unbounded) => x,
546            (Inclusive(a), Inclusive(b)) => {
547                if a >= b {
548                    Inclusive(a)
549                } else {
550                    Inclusive(b)
551                }
552            }
553            (Exclusive(a), Exclusive(b)) => {
554                if a >= b {
555                    Exclusive(a)
556                } else {
557                    Exclusive(b)
558                }
559            }
560            (Inclusive(a), Exclusive(b)) => {
561                if a > b {
562                    Inclusive(a)
563                } else if b > a {
564                    Exclusive(b)
565                } else {
566                    Exclusive(a)
567                }
568            }
569            (Exclusive(a), Inclusive(b)) => {
570                if a > b {
571                    Exclusive(a)
572                } else if b > a {
573                    Inclusive(b)
574                } else {
575                    Exclusive(a)
576                }
577            }
578        }
579    }
580
581    fn min(self, other: Self) -> Self {
582        use Bound::{Exclusive, Inclusive, Unbounded};
583
584        match (self, other) {
585            (Unbounded, x) | (x, Unbounded) => x,
586            (Inclusive(a), Inclusive(b)) => {
587                if a <= b {
588                    Inclusive(a)
589                } else {
590                    Inclusive(b)
591                }
592            }
593            (Exclusive(a), Exclusive(b)) => {
594                if a <= b {
595                    Exclusive(a)
596                } else {
597                    Exclusive(b)
598                }
599            }
600            (Inclusive(a), Exclusive(b)) => {
601                if a < b {
602                    Inclusive(a)
603                } else if b < a {
604                    Exclusive(b)
605                } else {
606                    Exclusive(a)
607                }
608            }
609            (Exclusive(a), Inclusive(b)) => {
610                if a < b {
611                    Exclusive(a)
612                } else if b < a {
613                    Inclusive(b)
614                } else {
615                    Exclusive(a)
616                }
617            }
618        }
619    }
620}
621
622#[derive(Clone, Copy)]
623enum ComparatorOp {
624    Greater,
625    GreaterEq,
626    Less,
627    LessEq,
628    Exact,
629}
630
631fn split_comparators(branch: &str) -> Vec<String> {
632    let mut tokens = Vec::new();
633    let chars: Vec<char> = branch.chars().collect();
634    let mut i = 0;
635
636    while i < chars.len() {
637        let ch = chars[i];
638        if ch == ',' {
639            i += 1;
640            continue;
641        }
642
643        if ch == '^' || ch == '~' {
644            let mut token = String::new();
645            token.push(ch);
646            i += 1;
647            while i < chars.len() {
648                let c = chars[i];
649                if c == ',' || c == '^' || c == '~' || c == '<' || c == '>' || c == '=' {
650                    break;
651                }
652                token.push(c);
653                i += 1;
654            }
655            tokens.push(token);
656            continue;
657        }
658
659        if ch == '<' || ch == '>' || ch == '=' {
660            let mut token = String::new();
661            token.push(ch);
662            i += 1;
663            if i < chars.len() && chars[i] == '=' {
664                token.push('=');
665                i += 1;
666            }
667            while i < chars.len() {
668                let c = chars[i];
669                if c == ',' || c == '^' || c == '~' || c == '<' || c == '>' || c == '=' {
670                    break;
671                }
672                token.push(c);
673                i += 1;
674            }
675            tokens.push(token);
676            continue;
677        }
678
679        // Plain version or hyphen range.
680        let mut token = String::new();
681        while i < chars.len() {
682            let c = chars[i];
683            if c == ',' || c == '^' || c == '~' || c == '<' || c == '>' || c == '=' {
684                break;
685            }
686            token.push(c);
687            i += 1;
688        }
689        if !token.is_empty() {
690            tokens.push(token);
691        }
692    }
693
694    tokens
695}
696
697fn parse_hyphen_range(comparator: &str) -> Option<(Version, Version)> {
698    let (left, right) = comparator.split_once('-')?;
699    let start = parse_plain_version(left)?;
700    let end = parse_plain_version(right)?;
701    Some((start, end))
702}
703
704fn parse_caret(comparator: &str) -> Option<(Version, u8)> {
705    let raw = comparator.strip_prefix('^')?;
706    let dots = raw.matches('.').count() as u8;
707    let version = parse_plain_version(raw)?;
708    Some((version, dots))
709}
710
711fn parse_tilde(comparator: &str) -> Option<Version> {
712    let raw = comparator.strip_prefix('~')?;
713    parse_plain_version(raw)
714}
715
716fn parse_operator_version(comparator: &str) -> Option<(ComparatorOp, Version)> {
717    if let Some(raw) = comparator.strip_prefix(">=") {
718        return parse_plain_version(raw).map(|v| (ComparatorOp::GreaterEq, v));
719    }
720    if let Some(raw) = comparator.strip_prefix("<=") {
721        return parse_plain_version(raw).map(|v| (ComparatorOp::LessEq, v));
722    }
723    if let Some(raw) = comparator.strip_prefix('>') {
724        return parse_plain_version(raw).map(|v| (ComparatorOp::Greater, v));
725    }
726    if let Some(raw) = comparator.strip_prefix('<') {
727        return parse_plain_version(raw).map(|v| (ComparatorOp::Less, v));
728    }
729    if let Some(raw) = comparator.strip_prefix('=') {
730        return parse_plain_version(raw).map(|v| (ComparatorOp::Exact, v));
731    }
732    None
733}
734
735fn parse_plain_version(raw: &str) -> Option<Version> {
736    if raw.is_empty() || raw == "*" {
737        return None;
738    }
739
740    let mut parts = raw.split('.');
741    let major_raw = parts.next()?;
742    let minor_raw = parts.next().unwrap_or("0");
743    let patch_raw = parts.next().unwrap_or("0");
744
745    if parts.next().is_some() {
746        return None;
747    }
748
749    // Allow wildcard suffixes like 0.8.* or 0.8.x as "any patch".
750    let major = major_raw.parse::<u64>().ok()?;
751    let minor = if minor_raw == "*" || minor_raw == "x" {
752        0
753    } else {
754        minor_raw.parse::<u64>().ok()?
755    };
756    let patch = if patch_raw == "*" || patch_raw == "x" {
757        0
758    } else {
759        patch_raw.parse::<u64>().ok()?
760    };
761
762    Some(Version {
763        major,
764        minor,
765        patch,
766    })
767}
768
769fn intersects_supported_neo_range(lower: Bound, upper: Bound) -> bool {
770    // Supported upstream Solidity ranges for this compiler compatibility layer.
771    (5u64..=8).any(|minor| {
772        intersects_semver_window(
773            lower,
774            upper,
775            Version {
776                major: 0,
777                minor,
778                patch: 0,
779            },
780            Version {
781                major: 0,
782                minor: minor + 1,
783                patch: 0,
784            },
785        )
786    })
787}
788
789fn intersects_semver_window(
790    lower: Bound,
791    upper: Bound,
792    target_start: Version,
793    target_end_exclusive: Version,
794) -> bool {
795
796    let effective_start = match lower {
797        Bound::Unbounded => target_start,
798        Bound::Inclusive(v) => v,
799        Bound::Exclusive(v) => next_patch(v),
800    };
801
802    let effective_end_exclusive = match upper {
803        Bound::Unbounded => target_end_exclusive,
804        Bound::Inclusive(v) => next_patch(v),
805        Bound::Exclusive(v) => v,
806    };
807
808    let range_start = if effective_start > target_start {
809        effective_start
810    } else {
811        target_start
812    };
813    let range_end = if effective_end_exclusive < target_end_exclusive {
814        effective_end_exclusive
815    } else {
816        target_end_exclusive
817    };
818
819    range_start < range_end
820}
821
822fn next_patch(version: Version) -> Version {
823    Version {
824        major: version.major,
825        minor: version.minor,
826        patch: version.patch.saturating_add(1),
827    }
828}
829
830/// Advance `pos` over ASCII whitespace in `bytes`, returning the index of the
831/// next non-whitespace byte (or the end of input).
832fn skip_whitespace_forward(bytes: &[u8], mut pos: usize) -> usize {
833    while pos < bytes.len() && bytes[pos].is_ascii_whitespace() {
834        pos += 1;
835    }
836    pos
837}
838
839/// Build a map from a DECLARATION's start position to the Natspec block that
840/// immediately precedes it. Each accumulated doc block is keyed at the first
841/// non-whitespace byte AFTER it (i.e. the start of the declaration it
842/// documents), so attachment is an exact lookup at the declaration's `start`
843/// — independent of distance, and never bleeding across an intervening
844/// declaration. A doc run is also broken when a non-whitespace token sits
845/// between two doc comments (that earlier doc belongs to the intervening
846/// declaration, not the later block).
847fn build_comment_map(comments: &[Comment], source: &str) -> HashMap<usize, NatspecDocIR> {
848    let mut map = HashMap::new();
849    let bytes = source.as_bytes();
850    // (end_of_last_doc_line, accumulated_text)
851    let mut pending: Option<(usize, String)> = None;
852
853    for comment in comments {
854        match comment {
855            Comment::DocLine(loc, text) | Comment::DocBlock(loc, text) => {
856                if let Loc::File(_, start, end) = loc {
857                    let clean_text = clean_doc_comment(text);
858                    let continues = match &pending {
859                        Some((prev_end, _)) => bytes
860                            .get(*prev_end..*start)
861                            .is_some_and(|gap| gap.iter().all(u8::is_ascii_whitespace)),
862                        None => false,
863                    };
864                    if continues {
865                        if let Some((prev_end, existing)) = pending.as_mut() {
866                            *prev_end = *end;
867                            existing.push('\n');
868                            existing.push_str(&clean_text);
869                        }
870                    } else {
871                        if let Some((prev_end, doc_text)) = pending.take() {
872                            map.insert(
873                                skip_whitespace_forward(bytes, prev_end),
874                                parse_natspec(&doc_text),
875                            );
876                        }
877                        pending = Some((*end, clean_text));
878                    }
879                }
880            }
881            Comment::Line(_loc, _) | Comment::Block(_loc, _) => {
882                // Regular comments break doc comment sequences.
883                if let Some((prev_end, doc_text)) = pending.take() {
884                    map.insert(
885                        skip_whitespace_forward(bytes, prev_end),
886                        parse_natspec(&doc_text),
887                    );
888                }
889            }
890        }
891    }
892
893    if let Some((prev_end, doc_text)) = pending.take() {
894        map.insert(
895            skip_whitespace_forward(bytes, prev_end),
896            parse_natspec(&doc_text),
897        );
898    }
899
900    map
901}
902
903/// Remove comment delimiters and leading asterisks/slashes
904fn clean_doc_comment(text: &str) -> String {
905    text.lines()
906        .map(|line| {
907            let trimmed = line.trim();
908            // Remove /// prefix from line doc comments
909            if let Some(rest) = trimmed.strip_prefix("///") {
910                rest.trim().to_string()
911            // Remove /** and */ from block doc comments
912            } else if let Some(rest) = trimmed.strip_prefix("/**") {
913                rest.trim_end_matches("*/").trim().to_string()
914            } else if let Some(rest) = trimmed.strip_suffix("*/") {
915                rest.trim().to_string()
916            // Remove leading * from block comment lines
917            } else if let Some(rest) = trimmed.strip_prefix('*') {
918                rest.trim().to_string()
919            } else {
920                trimmed.to_string()
921            }
922        })
923        .filter(|line| !line.is_empty())
924        .collect::<Vec<_>>()
925        .join("\n")
926}
927
928/// Parse Natspec tags from a documentation comment
929fn parse_natspec(text: &str) -> NatspecDocIR {
930    let mut doc = NatspecDocIR::default();
931    let mut current_tag: Option<&str> = None;
932    let mut current_content = String::new();
933
934    for line in text.lines() {
935        let trimmed = line.trim();
936
937        // Check for tag at start of line
938        if trimmed.starts_with('@') {
939            // Save previous tag content
940            if let Some(tag) = current_tag {
941                save_tag_content(&mut doc, tag, &current_content);
942            }
943
944            // Parse new tag
945            let parts: Vec<&str> = trimmed.splitn(2, char::is_whitespace).collect();
946            current_tag = Some(parts[0]);
947            current_content = parts
948                .get(1)
949                .map(|s| s.trim().to_string())
950                .unwrap_or_default();
951        } else if current_tag.is_some() {
952            // Continue previous tag content
953            if !current_content.is_empty() {
954                current_content.push(' ');
955            }
956            current_content.push_str(trimmed);
957        } else {
958            // No tag yet - treat as @notice
959            if doc.notice.is_none() && !trimmed.is_empty() {
960                doc.notice = Some(trimmed.to_string());
961            } else if let Some(ref mut notice) = doc.notice {
962                notice.push(' ');
963                notice.push_str(trimmed);
964            }
965        }
966    }
967
968    // Save final tag
969    if let Some(tag) = current_tag {
970        save_tag_content(&mut doc, tag, &current_content);
971    }
972
973    doc
974}
975
976fn save_tag_content(doc: &mut NatspecDocIR, tag: &str, content: &str) {
977    let content = content.trim().to_string();
978    if content.is_empty() {
979        return;
980    }
981
982    match tag {
983        "@title" => doc.title = Some(content),
984        "@author" => doc.author = Some(content),
985        "@notice" => doc.notice = Some(content),
986        "@dev" => doc.dev = Some(content),
987        "@param" => {
988            // Format: @param name description
989            let parts: Vec<&str> = content.splitn(2, char::is_whitespace).collect();
990            if parts.len() >= 2 {
991                doc.params
992                    .push((parts[0].to_string(), parts[1].trim().to_string()));
993            } else if !parts.is_empty() {
994                doc.params.push((parts[0].to_string(), String::new()));
995            }
996        }
997        "@return" => doc.returns.push(content),
998        tag if tag.starts_with("@custom:") => {
999            let custom_tag = tag.strip_prefix("@custom:").unwrap_or("");
1000            doc.custom.push((custom_tag.to_string(), content));
1001        }
1002        _ => {} // Ignore unknown tags
1003    }
1004}
1005
1006/// Find the doc comment that precedes a given declaration. `build_comment_map`
1007/// keys each doc block at the start of the declaration it documents, so this is
1008/// an exact lookup at the declaration's `start` — no fixed-distance backward
1009/// scan (which mis-attached across intervening tokens or missed docs separated
1010/// by more than 100 bytes of whitespace).
1011fn find_preceding_doc(loc: &Loc, comment_map: &HashMap<usize, NatspecDocIR>) -> NatspecDocIR {
1012    if let Loc::File(_, start, _) = loc {
1013        if let Some(doc) = comment_map.get(start) {
1014            return doc.clone();
1015        }
1016    }
1017    NatspecDocIR::default()
1018}