Estou trabalhando em uma biblioteca para uso em testes, onde quero poder ter variáveis que podem ser redefinidas em um escopo específico. Isso é inspirado em let
rspec .
Tenho algo funcionando, sombreando consistentemente uma variável chamada scope
(do tipo Scope
). O Scope
tipo tem
- um
apply[T](String)(Scope => T)
método para definir escopos filho e avaliar o bloco fornecido imediatamente com esse escopo filho como um argumento - um
let
método para refinar o valor de umaScoped
variável dentro deste escopo - um
get
método para obter o valor de umaScoped
variável dentro deste escopo
Um exemplo simplificado que funciona:
object MyTest extends Scopes with FunSuite {
val env = Scoped[String]("production")
val input = Scoped[Int]()
scope("when the input is zero") { scope =>
scope.let(input, 0)
test("the value equals zero") {
expect(scope.get(input) == 0)
}
scope("and the app is running in staging") { scope =>
scope.let(env, "staging")
test("the value still equals zero") {
expect(scope.get(input) == 0)
}
}
}
}
Isso funciona porque a Scopes
característica fornece val scope = Scope.root
, e contanto que você sombreie consistentemente a scope
variável toda vez que criar um escopo filho, os testes poderão se referir a ela scope
e ela será baseada no escopo lexical onde eles são definidos.
Âmbito implícito
A API seria menos detalhada se os métodos pudessem aceitar um implicit Scope
, e isso funciona (no Scala 3) se eu escrever consistentemente cada escopo aninhado como:
scope("nested scope") { implicit scope =>
// ...
}
Mas isso é mais detalhado na definição do escopo, embora possa melhorar a brevidade ao acessar ou redefinir variáveis com escopo.
Adicionando uma macro via implícita
Eu esperava poder reduzir o boilerplate por meio de macros. Essencialmente, quero adicionar automaticamente o implicit activeScope =>
boilerplate acima em cada chamada parascope
. Então o código do usuário ficaria assim:
scope("nested scope") {
// I can call a method taking (implicit scope: Scope)
}
Eu consegui fazer typecheck colocando um implicit val rootScope: Scope = ???
no Scopes
trait, e então tendo uma scope
macro que automaticamente introduz um implícito no começo do bloco passado para ele. A parte de geração de código da macro se parece com isso:
def contextCode[T](scope: Expr[Scope], s: Expr[String], block: Expr[T])(using Quotes, Type[T]): Expr[T] = {
'{
${scope}(${s}) { implicit activeScope =>
(${block})
}
}
}
Isso compila, mas não usa o implícito introduzido. Suspeito que, como a verificação de tipos acontece antes da expansão da macro, a resolução implícita também acontece (então meus novos implícitos são ignorados).
Existe alguma maneira de usar macros para afetar qual Scope
valor é selecionado com base no escopo léxico? Estou disposto a recorrer a alguns truques astutos, desde que sejam confiáveis.
Em termos de restrições, as scope
chamadas de definição externa são todas avaliadas no momento da criação do objeto, então eu ficaria bem em usar algum estado mutável para rastrear "o escopo ativo" no momento da definição. O problema é que o código dentro dos blocos de teste precisa acessar o mesmo escopo, e esse código roda muito mais tarde (potencialmente em paralelo), então o escopo "ativo" nos corpos de teste deve ser baseado em seu escopo léxico, não no estado de tempo de execução.
Ah, e eu não tenho nenhum controle sobre o framework de teste em si. Ou seja, eu não posso (por exemplo) modificar a test
função para fazer algo especial antes de executar o corpo de teste.
Você não precisa de macros, mas de funções de contexto e alguns utilitários (com
inline
):( scastie )
Toda vez que você usa a função de contexto aninhada (
?=>
), ogiven
interior ofusca ogiven
exterior, então você pode criar com segurança e previsibilidade uma cópia do valor fornecido, usá-lo no aninhamento, e isso não afetará o escopo externo - o que parece ser o que você está tentando fazer.Não me preocupei em tornar meu
Scoped
exemplo genérico, mas ele deve lhe dar alguma compreensão de como ofuscargiven
s com funções de contexto aninhadas.