char/avril
(wip) why'd you have to go and make things so complicaaaaated?
git clone https://git.t4t.associates/char/avril
a29d09f
main
1use std:: collections::{ BTreeMap , HashMap }; 2use std:: path:: PathBuf ; 3 4use oxc:: syntax::{ identifier:: is_identifier_name, keyword:: is_reserved_keyword}; 5use url:: Url ; 6 7use crate :: config::{ self , Error , Span , Value , ValueKind , invalid, object}; 8use crate :: import_map:: ImportMap ; 9 10# [ derive ( Debug )] 11pub struct Export { 12pub target : Url , 13pub span : Span , 14} 15 16# [ derive ( Debug , Clone , PartialEq , Eq )] 17pub enum Source { 18/// A local package directory. 19Local ( PathBuf ), 20/// A whole `jsr:` package, optionally with a version requirement. 21Jsr ( crate :: registry:: JsrRequest ), 22/// A whole `npm:` package, optionally with a version requirement. 23Npm ( crate :: registry:: npm:: NpmRequest ), 24Http ( Url ), 25} 26 27# [ derive ( Debug )] 28pub struct Dependency { 29pub source : Source , 30/// The binding, unless it is the name a local dependency declares for itself. 31pub name : Option < String >, 32/// Where the binding name is chosen. 33pub span : Span , 34} 35 36# [ derive ( Debug , Clone , PartialEq , Eq )] 37pub struct TaskDependency { 38pub name : String , 39pub span : Span , 40} 41 42# [ derive ( Debug , Clone , PartialEq , Eq )] 43pub struct TaskPattern { 44pub pattern : String , 45pub span : Span , 46} 47 48# [ derive ( Debug , Clone , PartialEq , Eq )] 49pub struct TaskEnvFile { 50pub path : String , 51pub span : Span , 52} 53 54# [ derive ( Debug , Clone , PartialEq , Eq )] 55pub enum TaskCommand { 56/// Run through the platform shell (`sh -c` / `cmd /C`). 57Shell ( String ), 58/// Spawn directly, without a shell. 59Argv ( Vec < String >), 60} 61 62impl TaskCommand { 63/// A one-line rendering for task listings. 64pub fn display ( & self ) ->String { 65match self { 66TaskCommand :: Shell ( command) => command. clone (), 67TaskCommand :: Argv ( argv) => argv. join ( " " ), 68} 69} 70} 71 72# [ derive ( Debug , Clone , PartialEq , Eq )] 73pub struct Task { 74pub command : Option < TaskCommand >, 75pub description : Option < String >, 76pub dependencies : Vec < TaskDependency >, 77/// Run `dependencies` all at once instead of sequentially. 78pub parallel : bool , 79/// A dotenv file applied to this task and, recursively, its dependencies. 80pub env_file : Option < TaskEnvFile >, 81/// Declaring `files` opts the task into result caching. 82pub files : Option < Vec < TaskPattern >>, 83pub output : Vec < TaskPattern >, 84/// Environment variable names whose values join the cache key. 85pub env : Vec < String >, 86pub span : Span , 87} 88 89# [ derive ( Debug , Default )] 90pub struct TypeConfig { 91pub imports : ImportMap , 92pub globals : Vec < String >, 93} 94 95# [ derive ( Debug )] 96pub struct PackageConfig { 97pub name : Option < String >, 98pub version : Option < String >, 99pub description : Option < String >, 100pub exports : BTreeMap < String , Export >, 101pub dependencies : Vec < Dependency >, 102pub tasks : BTreeMap < String , Task >, 103pub import_map : ImportMap , 104pub types : TypeConfig , 105/// Each registry dependency's binding mapped to its package and that 106/// package's exports: `jsr:` and `npm:` alike. 107pub registry_map : ImportMap , 108/// Package-wide defaults for the supported JSX `compilerOptions`. 109pub compiler_options : serde_json:: Map < String , serde_json:: Value >, 110name_span : Option < Span >, 111} 112 113impl PackageConfig { 114pub fn parse ( source : & str , base : & Url ) ->Result < Self , Error > { 115Self :: from_value ( & config:: parse ( source) ?, base) 116} 117 118pub ( crate ) fn from_value ( root : & Value , base : & Url ) ->Result < Self , Error > { 119if !matches! ( base. scheme (), "file" |"http" |"https" ) { 120return Err ( invalid ( 121 root. span . clone (), 122"Package configs require a file or HTTP(S) URL" , 123)); 124} 125let directory = base 126. join ( "./" ) 127. map_err ( |error|invalid ( root. span . clone (), error. to_string ())) ?; 128let mut package =Self { 129name : None , 130version : None , 131description : None , 132exports : BTreeMap :: new (), 133dependencies : Vec :: new (), 134tasks : BTreeMap :: new (), 135import_map : ImportMap :: default (), 136types : TypeConfig :: default (), 137registry_map : ImportMap :: default (), 138compiler_options : serde_json:: Map :: new (), 139name_span : None , 140}; 141let mut imports =None ; 142let mut scopes =None ; 143let mut type_config =None ; 144for memberin object ( root) ?{ 145match member. key . as_str () { 146"name" =>{ 147let name =string ( & member. value ) ?; 148if !valid_name ( name) { 149return Err ( invalid ( 150 member. value . span . clone (), 151"Expected a bare package name, optionally scoped" , 152)); 153} 154 package. name =Some ( name. to_owned ()); 155 package. name_span =Some ( member. value . span . clone ()); 156} 157"version" =>{ 158let version =string ( & member. value ) ?; 159 deno_semver:: Version :: parse_standard ( version). map_err ( |error|{ 160invalid ( 161 member. value . span . clone (), 162format! ( "Invalid package version: {error}" ), 163) 164}) ?; 165 package. version =Some ( version. to_owned ()); 166} 167"description" => package. description =Some ( string ( & member. value ) ?. to_owned ()), 168"exports" =>{ 169let entries: Vec < _ > =match & member. value . kind { 170ValueKind :: String ( _) =>{ 171vec! [( "." , & member. value , member. value . span . clone ())] 172} 173 _ =>object ( & member. value ) ? 174. iter () 175. map ( |entry|( entry. key . as_str (), & entry. value , entry. key_span . clone ())) 176. collect (), 177}; 178for ( key, value, span) in entries{ 179if key !="." 180 &&( !key. starts_with ( "./" ) 181 || key[ 2 ..] 182. split ( '/' ) 183. any ( |part| part. is_empty () ||matches! ( part, "." |".." )) 184 || key. contains ([ '*' , '?' , '#' , '\\' ]) 185 || key. chars (). any ( char:: is_control)) 186{ 187return Err ( invalid ( 188 span, 189"Exports must use '.' or exact './subpath' keys" , 190)); 191} 192let target =string ( value) ?; 193if !target. starts_with ( "./" ) { 194return Err ( invalid ( 195 value. span . clone (), 196"Export targets must be package-relative files starting with './'" , 197)); 198} 199let url =package_url ( target, base, & value. span ) ?; 200if !url. as_str (). starts_with ( directory. as_str ()) 201 || url == directory 202 || url. path (). ends_with ( '/' ) 203{ 204return Err ( invalid ( 205 value. span . clone (), 206"Export target must be a file inside its package" , 207)); 208} 209 package. exports . insert ( 210 key. to_owned (), 211Export { 212target : url, 213span : value. span . clone (), 214}, 215); 216} 217} 218"imports" => imports =Some ( & member. value ), 219"scopes" => scopes =Some ( & member. value ), 220"types" => type_config =Some ( & member. value ), 221"compilerOptions" =>{ 222for optionin object ( & member. value ) ?{ 223// Every key is retained now: non-JSX values steer type 224// checking, so an unconvertible value errors at its own span. 225let value = option. value . to_value () ?; 226check_jsx_option ( & option. key , & value) 227. map_err ( |message|invalid ( option. value . span . clone (), message)) ?; 228 package. compiler_options . insert ( option. key . clone (), value); 229} 230} 231"deps" =>{ 232let ValueKind :: Array ( entries) =& member. value . kind else { 233return Err ( invalid ( member. value . span . clone (), "Expected an array" )); 234}; 235for entryin entries{ 236 package. dependencies . push ( dependency ( entry, base) ?); 237} 238} 239"tasks" =>{ 240for entryin object ( & member. value ) ?{ 241if !valid_task_name ( & entry. key ) { 242return Err ( invalid ( 243 entry. key_span . clone (), 244"Task names must be non-empty and use only letters, digits, '-', '_', '.', or ':'" , 245)); 246} 247 package. tasks . insert ( entry. key . clone (), task ( & entry. value ) ?); 248} 249} 250 _ =>{} 251} 252} 253 package. import_map =ImportMap :: from_fields ( imports, scopes, base) ?; 254if let Some ( type_config) = type_config{ 255 package. types =parse_type_config ( type_config, base) ?; 256} 257if let Some (( name, span)) = package. name . as_ref (). zip ( package. name_span . as_ref ()) 258 &&let Some ( key) = package. import_map . namespace_conflict ( name) 259{ 260return Err ( invalid ( 261 span. clone (), 262format! ( "Package name {name:?} conflicts with import key {key:?}" ), 263)); 264} 265let mut bindings =HashMap :: new (); 266for dependencyin & package. dependencies { 267if let Some ( name) =& dependency. name { 268 package. check_binding ( name, & dependency. span , & mut bindings) ?; 269} 270} 271let packages: Vec <( String , String )> = package 272. dependencies 273. iter () 274. filter_map ( |dependency|match ( & dependency. name , & dependency. source ) { 275( Some ( name), Source :: Jsr ( request)) =>{ 276Some (( name. clone (), request. specifier . clone ())) 277} 278( Some ( name), Source :: Npm ( request)) =>Some (( name. clone (), request. request_key ())), 279 _ =>None , 280}) 281. collect (); 282 package. registry_map =ImportMap :: packages ( packages); 283Ok ( package) 284} 285 286/// Binds each dependency, asking `declared` for the declared name of any local 287/// dependency listed by path alone. Errors are reported against this config. 288pub async fn bindings < F >( 289& self , 290mut declared : impl FnMut ( Source ) ->F , 291) ->Result < BTreeMap < String , Source >, Error > 292where 293F : std:: future:: Future < Output =Result < Option < String >, String >>, 294{ 295let mut spans =HashMap :: new (); 296let mut declarers =HashMap :: new (); 297let mut bindings =BTreeMap :: new (); 298for dependencyin & self . dependencies { 299let ( name, declarer) =match ( & dependency. name , & dependency. source ) { 300( Some ( name), _) =>( name. clone (), None ), 301( None , Source :: Jsr ( _)) =>unreachable! ( "jsr: dependencies are named when parsed" ), 302( None , Source :: Npm ( _)) =>unreachable! ( "npm: dependencies are named when parsed" ), 303( None , source) =>{ 304let config =match source{ 305Source :: Local ( directory) =>{ 306 directory. join ( "avril.conf" ). display (). to_string () 307} 308Source :: Http ( directory) =>{ 309 directory. join ( "avril.conf" ). unwrap (). to_string () 310} 311Source :: Jsr ( _) =>unreachable! (), 312Source :: Npm ( _) =>unreachable! (), 313}; 314let name =declared ( source. clone ()) 315. await 316. map_err ( |error|{ 317invalid ( 318 dependency. span . clone (), 319format! ( 320"Cannot read the name of dependency {}: {error}" , 321 config 322), 323) 324}) ? 325. ok_or_else ( ||{ 326invalid ( 327 dependency. span . clone (), 328format! ( 329"Dependency {} declares no name; give this entry {{from, name}}" , 330 config 331), 332) 333}) ?; 334( name, Some ( config)) 335} 336}; 337self . check_binding ( & name, & dependency. span , & mut spans) 338. map_err ( |mut error|{ 339// A duplicate of an earlier inferred name needs that dependency's context. 340if let Some ( config) = declarer. as_ref (). or_else ( || declarers. get ( & name)) { 341 error. message =format! ( 342"{}; the dependency's name comes from {}" , 343 error. message , config 344); 345} 346 error 347}) ?; 348if let Some ( config) = declarer{ 349 declarers. insert ( name. clone (), config); 350} 351 bindings. insert ( name, dependency. source . clone ()); 352} 353Ok ( bindings) 354} 355 356fn check_binding ( 357& self , 358name : & str , 359span : & Span , 360bindings : & mut HashMap < String , Span >, 361) ->Result <(), Error > { 362if self . name . as_deref () ==Some ( name) { 363return Err ( Error { 364kind : crate :: config:: ErrorKind :: Syntax , 365message : format! ( 366"Dependency binding {name:?} conflicts with the package's self name" 367), 368span : span. clone (), 369previous : self . name_span . clone (), 370}); 371} 372if let Some ( previous) = bindings. get ( name) { 373return Err ( Error { 374kind : crate :: config:: ErrorKind :: Syntax , 375message : format! ( "Duplicate dependency binding {name:?}" ), 376span : span. clone (), 377previous : Some ( previous. clone ()), 378}); 379} 380if let Some ( key) =self . import_map . namespace_conflict ( name) { 381return Err ( invalid ( 382 span. clone (), 383format! ( "Dependency binding {name:?} conflicts with import key {key:?}" ), 384)); 385} 386 bindings. insert ( name. to_owned (), span. clone ()); 387Ok (()) 388} 389} 390 391fn valid_task_name ( name : & str ) ->bool { 392 !name. is_empty () 393 && name 394. bytes () 395. all ( |ch| ch. is_ascii_alphanumeric () ||matches! ( ch, b'-' |b'_' |b'.' |b':' )) 396} 397 398fn task ( value : & Value ) ->Result < Task , Error > { 399let span = value. span . clone (); 400let command = |value : & Value | ->Result < TaskCommand , Error > { 401match & value. kind { 402ValueKind :: Array ( entries) =>{ 403if entries. is_empty () { 404return Err ( invalid ( 405 value. span . clone (), 406"Task command arrays cannot be empty" , 407)); 408} 409let mut argv =Vec :: new (); 410for entryin entries{ 411 argv. push ( string ( entry) ?. to_owned ()); 412} 413if argv[ 0 ]. is_empty () { 414return Err ( invalid ( 415 entries[ 0 ]. span . clone (), 416"The first element of a task command array cannot be empty" , 417)); 418} 419Ok ( TaskCommand :: Argv ( argv)) 420} 421 _ =>{ 422let command =string ( value). map_err ( |_|{ 423invalid ( 424 value. span . clone (), 425"Expected a string (shell command) or an array of strings (argv) for 'command'" , 426) 427}) ?; 428if command. trim (). is_empty () { 429return Err ( invalid ( value. span . clone (), "Task commands cannot be empty" )); 430} 431Ok ( TaskCommand :: Shell ( command. to_owned ())) 432} 433} 434}; 435match & value. kind { 436ValueKind :: String ( _) =>Ok ( Task { 437command : Some ( command ( value) ?), 438description : None , 439dependencies : Vec :: new (), 440parallel : false , 441env_file : None , 442files : None , 443output : Vec :: new (), 444env : Vec :: new (), 445 span, 446}), 447ValueKind :: Object ( fields) =>{ 448let mut task =Task { 449command : None , 450description : None , 451dependencies : Vec :: new (), 452parallel : false , 453env_file : None , 454files : None , 455output : Vec :: new (), 456env : Vec :: new (), 457span : span. clone (), 458}; 459let mut dependency_spans: HashMap < String , Span > =HashMap :: new (); 460let mut parallel_span =None ; 461for fieldin fields{ 462match field. key . as_str () { 463"command" => task. command =Some ( command ( & field. value ) ?), 464"description" => task. description =Some ( string ( & field. value ) ?. to_owned ()), 465"dependencies" =>{ 466let ValueKind :: Array ( entries) =& field. value . kind else { 467return Err ( invalid ( 468 field. value . span . clone (), 469"Expected an array of task names" , 470)); 471}; 472for entryin entries{ 473let name =string ( entry) ?; 474if !valid_task_name ( name) { 475return Err ( invalid ( entry. span . clone (), "Expected a task name" )); 476} 477if let Some ( previous) = dependency_spans. get ( name) { 478return Err ( Error { 479kind : crate :: config:: ErrorKind :: Syntax , 480message : format! ( "Duplicate task dependency {name:?}" ), 481span : entry. span . clone (), 482previous : Some ( previous. clone ()), 483}); 484} 485 dependency_spans. insert ( name. to_owned (), entry. span . clone ()); 486 task. dependencies . push ( TaskDependency { 487name : name. to_owned (), 488span : entry. span . clone (), 489}); 490} 491} 492"parallel" =>{ 493let ValueKind :: Bool ( parallel) = field. value . kind else { 494return Err ( invalid ( field. value . span . clone (), "Expected a boolean" )); 495}; 496 task. parallel = parallel; 497 parallel_span =Some ( field. value . span . clone ()); 498} 499"envFile" =>{ 500let path =string ( & field. value ) ?; 501if path. trim (). is_empty () { 502return Err ( invalid ( 503 field. value . span . clone (), 504"Task env file paths cannot be empty" , 505)); 506} 507 task. env_file =Some ( TaskEnvFile { 508path : path. to_owned (), 509span : field. value . span . clone (), 510}); 511} 512"files" |"output" =>{ 513let ValueKind :: Array ( entries) =& field. value . kind else { 514return Err ( invalid ( 515 field. value . span . clone (), 516"Expected an array of glob patterns" , 517)); 518}; 519let mut patterns =Vec :: new (); 520for entryin entries{ 521let pattern =string ( entry) ?; 522 avril_task:: validate_pattern ( pattern) 523. map_err ( |error|invalid ( entry. span . clone (), error. to_string ())) ?; 524if field. key =="output" { 525 avril_task:: validate_output_pattern ( pattern). map_err ( |error|{ 526invalid ( entry. span . clone (), error. to_string ()) 527}) ?; 528} 529 patterns. push ( TaskPattern { 530pattern : pattern. to_owned (), 531span : entry. span . clone (), 532}); 533} 534if field. key =="files" { 535 task. files =Some ( patterns); 536} else { 537 task. output = patterns; 538} 539} 540"env" =>{ 541let ValueKind :: Array ( entries) =& field. value . kind else { 542return Err ( invalid ( 543 field. value . span . clone (), 544"Expected an array of environment variable names" , 545)); 546}; 547let mut seen: HashMap < String , Span > =HashMap :: new (); 548for entryin entries{ 549let name =string ( entry) ?; 550if !valid_env_name ( name) { 551return Err ( invalid ( 552 entry. span . clone (), 553"Expected an environment variable name" , 554)); 555} 556if let Some ( previous) = seen. get ( name) { 557return Err ( Error { 558kind : crate :: config:: ErrorKind :: Syntax , 559message : format! ( "Duplicate task env name {name:?}" ), 560span : entry. span . clone (), 561previous : Some ( previous. clone ()), 562}); 563} 564 seen. insert ( name. to_owned (), entry. span . clone ()); 565 task. env . push ( name. to_owned ()); 566} 567} 568 _ =>{} 569} 570} 571if task. parallel && task. dependencies . is_empty () { 572return Err ( invalid ( 573 parallel_span. unwrap (), 574"Task 'parallel' requires 'dependencies'" , 575)); 576} 577if task. files . is_none () &&( !task. output . is_empty () || !task. env . is_empty ()) { 578return Err ( invalid ( 579 span. clone (), 580"Task 'output' and 'env' require 'files': declaring 'files' enables result caching" , 581)); 582} 583if task. command . is_none () && task. dependencies . is_empty () { 584return Err ( invalid ( 585 span, 586"Tasks must define a 'command' or 'dependencies'" , 587)); 588} 589Ok ( task) 590} 591 _ =>Err ( invalid ( 592 span, 593"Expected a task command string or an object with 'command', 'description', 'dependencies', 'parallel', 'envFile', 'files', 'output', or 'env'" , 594)), 595} 596} 597 598fn valid_env_name ( name : & str ) ->bool { 599let mut bytes = name. bytes (); 600 bytes 601. next () 602. is_some_and ( |ch| ch. is_ascii_alphabetic () || ch ==b'_' ) 603 && bytes. all ( |ch| ch. is_ascii_alphanumeric () || ch ==b'_' ) 604} 605 606fn dependency ( entry : & Value , base : & Url ) ->Result < Dependency , Error > { 607let ( from, name) =match & entry. kind { 608ValueKind :: String ( _) =>( entry, None ), 609ValueKind :: Object ( _) =>{ 610let mut from =None ; 611let mut name =None ; 612for fieldin object ( entry) ?{ 613match field. key . as_str () { 614"from" => from =Some ( & field. value ), 615"name" => name =Some ( & field. value ), 616 _ =>{} 617} 618} 619let ( Some ( from), Some ( name)) =( from, name) else { 620return Err ( invalid ( 621 entry. span . clone (), 622"Dependency objects require both 'from' and 'name'; a string uses the dependency's own name" , 623)); 624}; 625( from, Some ( name)) 626} 627 _ =>{ 628return Err ( invalid ( 629 entry. span . clone (), 630"Expected a dependency path, HTTP(S) directory URL, jsr: package, or {from, name}" , 631)); 632} 633}; 634let binding =match name{ 635Some ( name) =>{ 636let binding =string ( name) ?; 637if !valid_name ( binding) { 638return Err ( invalid ( 639 name. span . clone (), 640"Expected a dependency binding name, optionally scoped" , 641)); 642} 643Some ( binding. to_owned ()) 644} 645None =>None , 646}; 647let target =string ( from) ?; 648let ( source, implicit) =if target. starts_with ( "jsr:" ) { 649let span =& from. span ; 650let request =crate :: registry:: parse_jsr_request ( target) 651. map_err ( |error|invalid ( span. clone (), error. to_string ())) ?; 652if request. version_req . tag (). is_some () { 653return Err ( invalid ( 654 span. clone (), 655"JSR version tags are not supported; use a semver range" , 656)); 657} 658if request. subpath . is_some () || target. ends_with ( '/' ) { 659return Err ( invalid ( 660 span. clone (), 661"Dependencies are whole packages; map a subpath in imports instead" , 662)); 663} 664crate :: registry:: validate_name ( & request. name ) 665. map_err ( |error|invalid ( span. clone (), error. to_string ())) ?; 666let name = request. name . clone (); 667( Source :: Jsr ( request), Some ( name)) 668} else if target. starts_with ( "npm:" ) { 669let span =& from. span ; 670let request =crate :: registry:: npm:: parse_npm_request ( target) 671. map_err ( |error|invalid ( span. clone (), error. to_string ())) ?; 672if request. subpath . is_some () || target. ends_with ( '/' ) { 673return Err ( invalid ( 674 span. clone (), 675"Dependencies are whole packages; map a subpath in imports instead" , 676)); 677} 678let name = request. name . clone (); 679( Source :: Npm ( request), Some ( name)) 680} else if Url :: parse ( target). is_ok_and ( |url|matches! ( url. scheme (), "http" |"https" )) 681 ||( matches! ( base. scheme (), "http" |"https" ) 682 &&( target. starts_with ( "./" ) || target. starts_with ( "../" ))) 683{ 684let url =package_url ( target, base, & from. span ) ?; 685if !matches! ( url. scheme (), "http" |"https" ) 686 || !url. username (). is_empty () 687 || url. password (). is_some () 688 || !target. ends_with ( '/' ) 689{ 690return Err ( invalid ( 691 from. span . clone (), 692"HTTP(S) dependencies must be credential-free directory URLs ending with '/'" , 693)); 694} 695( Source :: Http ( url), None ) 696} else if target. starts_with ( "./" ) || target. starts_with ( "../" ) { 697let path =package_url ( target, base, & from. span ) ? 698. to_file_path () 699. map_err ( |_|invalid ( from. span . clone (), "Expected a local package path" )) ?; 700( Source :: Local ( path), None ) 701} else { 702return Err ( invalid ( 703 from. span . clone (), 704"Dependencies must be jsr: packages, HTTP(S) directory URLs, or config-relative paths starting with './' or '../'" , 705)); 706}; 707Ok ( Dependency { 708 source, 709name : binding. or ( implicit), 710span : name. unwrap_or ( from). span . clone (), 711}) 712} 713 714/// Whether `name` is a bare package name, optionally scoped. 715pub fn valid_name ( name : & str ) ->bool { 716let valid_part = |part : & str |{ 717 !part. is_empty () 718 && !part. starts_with ( '.' ) 719 && part 720. bytes () 721. all ( |ch| ch. is_ascii_alphanumeric () ||matches! ( ch, b'-' |b'_' |b'.' )) 722}; 723match name. strip_prefix ( '@' ) { 724Some ( scoped) => scoped 725. split_once ( '/' ) 726. is_some_and ( |( scope, name) |valid_part ( scope) &&valid_part ( name)), 727None =>valid_part ( name), 728} 729} 730 731/// The `compilerOptions` validated for the runtime; every key is retained 732/// for type checking, in `avril.conf` and `tsconfig.json` alike. 733pub const JSX_OPTIONS : [ & str ; 4 ] =[ "jsx" , "jsxImportSource" , "jsxFactory" , "jsxFragmentFactory" ]; 734 735pub fn check_jsx_option ( key : & str , value : & serde_json:: Value ) ->Result <(), String > { 736if !JSX_OPTIONS . contains ( & key) { 737return Ok (()); 738} 739let text = value 740. as_str () 741. filter ( |text| !text. trim (). is_empty ()) 742. ok_or_else ( ||format! ( "compilerOptions.{key} must be a nonempty string" )) ?; 743if key =="jsx" 744 && !matches! ( 745 text, 746"react" |"react-jsx" |"react-jsxdev" |"preserve" |"react-native" 747) 748{ 749return Err ( "Unsupported compilerOptions.jsx" . into ()); 750} 751if matches! ( key, "jsxFactory" |"jsxFragmentFactory" ) { 752let mut parts = text. split ( '.' ); 753let root = parts. next (). unwrap (); 754let valid =match root{ 755"this" =>true , 756"import" => parts. next () ==Some ( "meta" ), 757 _ =>is_identifier_name ( root) && !is_reserved_keyword ( root), 758}; 759if !valid || !parts. all ( is_identifier_name) { 760return Err ( format! ( 761"compilerOptions.{key} must be an identifier or dotted member expression" 762)); 763} 764} 765Ok (()) 766} 767 768fn parse_type_config ( value : & Value , base : & Url ) ->Result < TypeConfig , Error > { 769let members =object ( value) ?; 770let mut imports =None ; 771let mut globals =Vec :: new (); 772for memberin members{ 773match member. key . as_str () { 774"imports" => imports =Some ( & member. value ), 775"globals" =>{ 776let ValueKind :: Array ( entries) =& member. value . kind else { 777return Err ( invalid ( member. value . span . clone (), "Expected an array" )); 778}; 779for entryin entries{ 780let target =string ( entry) ?; 781let url =if target. starts_with ( "./" ) 782 || target. starts_with ( "../" ) 783 || target. starts_with ( '/' ) 784{ 785 base. join ( target) 786. map_err ( |error|invalid ( entry. span . clone (), error. to_string ())) ? 787} else { 788Url :: parse ( target). map_err ( |_|{ 789invalid ( 790 entry. span . clone (), 791"Global type entries must be URLs or relative paths" , 792) 793}) ? 794}; 795 globals. push ( url. to_string ()); 796} 797} 798 _ =>{} 799} 800} 801Ok ( TypeConfig { 802imports : ImportMap :: from_fields ( imports, None , base) ?, 803 globals, 804}) 805} 806 807fn string ( value : & Value ) ->Result < & str , Error > { 808match & value. kind { 809ValueKind :: String ( value) =>Ok ( value), 810 _ =>Err ( invalid ( value. span . clone (), "Expected a string" )), 811} 812} 813 814fn package_url ( target : & str , base : & Url , span : & Span ) ->Result < Url , Error > { 815let lower = target. to_ascii_lowercase (); 816if target. contains ( '\\' ) 817 || target. chars (). any ( char:: is_control) 818 || lower. contains ( "%2f" ) 819 || lower. contains ( "%5c" ) 820{ 821return Err ( invalid ( span. clone (), "Invalid package path" )); 822} 823let url = base 824. join ( target) 825. map_err ( |error|invalid ( span. clone (), error. to_string ())) ?; 826if url. query (). is_some () || url. fragment (). is_some () { 827return Err ( invalid ( 828 span. clone (), 829"Package paths cannot contain queries or fragments" , 830)); 831} 832let decoded = percent_encoding:: percent_decode_str ( url. path ()) 833. decode_utf8 () 834. map_err ( |_|invalid ( span. clone (), "Package paths must be valid UTF-8" )) ?; 835if decoded. contains ( '\0' ) { 836return Err ( invalid ( 837 span. clone (), 838"Package paths must be without NUL bytes" , 839)); 840} 841Ok ( url) 842} 843 844# [ cfg ( test )] 845mod tests{ 846use super :: PackageConfig ; 847use url:: Url ; 848 849const BASE : & str ="https://example.test/project/avril.conf" ; 850 851# [ test ] 852fn type_config_normalizes_global_modules_and_keeps_imports_separate () { 853let config =PackageConfig :: parse ( 854r#"{ 855imports: { "npm:legacy-sdk": "npm:runtime-sdk" }, 856types: { 857imports: { "npm:legacy-sdk": "npm:@types/legacy-sdk@^3", "https://example.test/project/src/old": "./types/legacy.d.ts", "legacy/": null }, 858globals: ["./types/globals.d.ts", "npm:@types/node@^22", "https://types.test/global.d.ts"] 859} 860}"# , 861& Url :: parse ( BASE ). unwrap (), 862) 863. unwrap (); 864assert_eq! ( 865 config. types . globals , 866[ 867"https://example.test/project/types/globals.d.ts" , 868"npm:@types/node@^22" , 869"https://types.test/global.d.ts" , 870] 871); 872let referrer =Url :: parse ( "https://example.test/project/src/module.ts" ). unwrap (); 873assert_eq! ( 874 config 875. types 876. imports 877. resolve ( "npm:legacy-sdk" , & referrer) 878. unwrap (), 879Some ( "npm:@types/legacy-sdk@^3" . into ()) 880); 881assert_eq! ( 882 config 883. import_map 884. resolve ( "npm:legacy-sdk" , & referrer) 885. unwrap (), 886Some ( "npm:runtime-sdk" . into ()) 887); 888assert_eq! ( 889 config. types . imports . resolve ( "./old" , & referrer). unwrap (), 890Some ( "https://example.test/project/types/legacy.d.ts" . into ()) 891); 892assert! ( 893 config 894. types 895. imports 896. resolve ( "legacy/subpath" , & referrer) 897. is_err () 898); 899} 900 901# [ test ] 902fn type_config_defaults_empty_and_rejects_malformed_shapes () { 903let base =Url :: parse ( BASE ). unwrap (); 904let config =PackageConfig :: parse ( "{}" , & base). unwrap (); 905assert! ( config. types . globals . is_empty ()); 906assert! ( 907 config 908. types 909. imports 910. resolve ( "anything" , & base) 911. unwrap () 912. is_none () 913); 914for sourcein [ 915r#"{ types: [] }"# , 916r#"{ types: { imports: [] } }"# , 917r#"{ types: { globals: "./global.d.ts" } }"# , 918r#"{ types: { globals: ["bare-module"] } }"# , 919r#"{ types: { globals: [42] } }"# , 920] { 921assert! ( PackageConfig :: parse ( source, & base). is_err (), "{source}" ); 922} 923} 924}