TECHNOLOGY MATTERS
pensamentos desordenados sobre tecnologia, software, etc.

ler esse site em: |

Analisando código com Sprache - Parte 2

😎 Este é o segundo artigo de uma série de múltiplas partes sobre como analisar código com Sprache. Você pode ler a primeira parte aqui.

No post anterior, vimos como analisar algum texto (em particular, código Java) usando Sprache, uma poderosa biblioteca para C#. Vimos como usar uma abordagem incremental e como usar testes unitários para conduzir o desenvolvimento com esta ferramenta. Até agora, escrevemos um analisador para Identifier e PackageName (verifique aqui).

Agora vamos avançar um pouco mais rápido. Lembre-se de que temos como alvo o source Java/Android Google Authenticator e que nosso objetivo final é gerar um gráfico de dependências de classe para este projeto. Neste artigo, vamos tentar analisar todos os elementos de nível superior do arquivo atual em que estamos trabalhando, AuthenticatorActivity.java.

❗ Em todo o código do blog, eliminei a documentação interna para maior clareza. Verifique o repo para ver os comentários.

Refatorando nosso caminho para o sucesso

Antes de incrementar o código atual, identifiquei um pequeno ajuste que nos ajudará na próxima etapa. Veja, o analisador PackageName está, na verdade, analisando uma instrução package (package statement). Nomes de pacotes são usados ​​em outros lugares, portanto, facilitaremos a reutilização extraindo um PackageName do analisador atual.

Primeiro, renomeie o analisador JavaGrammar.PackageName para JavaGrammar.PackageStament, o nome correto para o que está analisando; use suas ferramentas de refatoração de IDE para isso. Você também precisa renomear a unidade de teste anterior de PackageNameParserTests para PackageStatementParserTests para manter as coisas coerentes.

A seguir, vamos extrair a análise de um PackageName de JavaGrammar.PackageStament. Veja as linhas abaixo:

public static readonly Parser<PackageName> PackageStatement =
	from packageKeyword in Sprache.Parse.String("package").Once()
	from space in Sprache.Parse.WhiteSpace.Many()
	// ↓↓↓↓↓↓↓ Parsing of Package Name ↓↓↓↓↓↓↓
	from packageHead in Identifier
	from packageTail in (from delimiter in Sprache.Parse.Char('.').Once()
							from identifier in Identifier
							select identifier).Many()
	// ↑↑↑↑↑↑↑ Parsing of Package Name ↑↑↑↑↑↑↑
	from terminator in Sprache.Parse.Char(';')
	// ↓↓↓↓↓↓↓ And this is how the result is build ↓↓↓↓↓↓↓
	select new PackageName(new[] { packageHead }.Concat(packageTail).ToList());
	// ↑↑↑↑↑↑↑ And this is how the result is build ↑↑↑↑↑↑↑↑

Vamos extrair isso em um analisador isolado, este na verdade chamado PackageName:

public static readonly Parser<PackageName> PackageName =
	from packageHead in Identifier
	from packageTail in (from delimiter in Sprache.Parse.Char('.').Once()
						 from identifier in Identifier
						 select identifier).Many()
	select new PackageName(new[] { packageHead }.Concat(packageTail).ToList());

Precisamos criar uma estrutura para representar a instrução package:

public class PackageStatement
{
	public PackageStatement(PackageName packageName)
	{
		PackageName = packageName;
	}

	public PackageName PackageName { get; }
}

Vamos atualizar o analisador PackageStatement para retornar a estrutura, usando o analisador PackageName:

public static readonly Parser<PackageName> PackageStatement =
	from packageKeyword in Sprache.Parse.String("package").Once()
	from space in Sprache.Parse.WhiteSpace.Many()
	from packageName in PackageName
	from terminator in Sprache.Parse.Char(';')
	select packageName;

Execute todos os testes e você descobrirá que tudo está funcionando como deveria. Esta refatoração foi confirmada sob a tag Refactor_PackageName.

Analisando mais estruturas

Vamos voltar à criação de novos analisadores. A próxima estrutura natural é uma declaração de importação que tem o seguinte BNF:

Declaração de importação

IMPORT = "import", PACKAGE_NAME, ";";

Vamos começar atualizando nossa classe Import anterior. Mude seu nome de Import para ImportStatement, que é mais exato. Use sua ferramenta de refatoração IDE para renomear a classe. Eu também criei um construtor para ela que inicializa o PackageName:

public class ImportStatement
{
	public ImportStatement(PackageName packageName)
	{
		PackageName = packageName;
	}

	public PackageName PackageName { get; }
}

Agora, vamos criar um teste para isso:

😎 Estou criando um arquivo de teste para cada analisador, embora eles (atualmente) sejam todos parte da mesma classe. Isso não é padrão; é a melhor prática ter uma classe de teste de unidade por classe. Entretanto, fazer dessa forma torna mais fácil localizar os testes e nos permite mantê-los mais organizados.

public class ImportStatementParserTests
{
	[Theory]
	[InlineData("import android.annotation.TargetApi;")]
	[InlineData("import android.app.Activity;")]
	public void Parse_WhenValidPackageName_ReturnsStructureWithCorrectName(string importStatement)
	{
		var actual = JavaGrammar.ImportStatement.Parse(importStatement);

		Assert.Equal(packageName, string.Join('.', actual.PackageName.Identifiers));
	}
}

Usamos as primeiras instruções de import de AuthenticatorActivity, como você pode ver. Também estamos fazendo um join dos identificadores assim como fizemos da última vez, o que é um pouco tedioso; vamos melhorar isso na etapa de refatorar.

A execução desse teste levará a 🔴, pois o código não foi implementado. Vamos escrever o analisador agora:

public static readonly Parser<ImportStatement> ImportStatement =
	from importKeyword in Parse.String("import").Token()
	from packageName in PackageName.Token()
	from delimiter in Parse.Char(';').Token()
	select new ImportStatement(packageName);

Leitores ávidos notarão o método anteriormente não visto Token() na segunda linha. Este é um dos métodos mais úteis no Sprache - removerá os espaços em branco ao redor do caractere, mas * exigirá * que ele esteja lá. Isso significa que, por exemplo, Parse.String("import").Token().Parse(" import ") funcionará, mas Parse.String("import").Token().Parse(" importasd ") não.

O resto deve ser muito legível - procure a palavra-chave import, depois uma estrutura PackageName e retorne uma instrução import. Execute todos os testes. Você deve obter um 🟢.

Continuando, vamos refatorar um pouco. Lembra que eu disse que faríamos algo sobre string.Join('.', actual.PackageName.Identifiers)? Agora é a hora!

A representação de string de um nome de pacote, digamos, “android.content.ActivityNotFoundException” não é outro senão “android.content.ActivityNotFoundException”. Então, o que você acha de substituir PackageName.ToString para cumprir esse comportamento?

public class PackageName
{
	// ...

	public override string ToString()
	{
		return string.Join('.', Identifiers);
	}
}

😎 DICA: Ao realizer um override em métodos e propriedades, use o xml doc /// <inheritdoc /> como documentação.

Agora, seguindo a mesma ideia, podemos adicionar uma substituição de ToString em ImportStatement:

public class ImportStatement
{
	// ...

	public override string ToString()
	{
		return $"import {PackageName};";
	}
}

Agora podemos atualizar nosso teste de unidade para refletir este refator:

[Theory]
[InlineData("import android.annotation.TargetApi;")]
[InlineData("import android.app.Activity;")]
public void Parse_WhenValidPackageName_ReturnsStructureWithCorrectName(string importStatement)
{
	var actual = JavaGrammar.ImportStatement.Parse(importStatement);

	Assert.Equal(importStatement, actual.ToString());
}

Aviso: neste caso, a entrada é formatada exatamente como a saída de ImportStatement.ToString. No entanto, se você analisar algo como " import name.surname", enquanto a análise funcionará, o ToString retornará "import name.surname", sem espaços. Isso significa que a regra Token () não está sendo testada em nossa suíte - a razão para isso é que ela não aparece em nosso escopo - portanto, os testes são um pouco frágeis e devem ser melhorados na maioria das circunstâncias. Não vou fazer isso durante esses exercícios, mas os leitores devem definitivamente fazê-lo.

Agora, precisamos ler um bloco de importações em nosso arquivo. Chamamos isso de IMPORT_LIST no eBNF. A estrutura de dados para isso não precisa ser mais do que List <ImportStatement>, mas precisamos de um analisador para isso. Comece com um teste:

public class ImportListParserTests
{
	public static IEnumerable<object[]> ImportLists()
	{
		yield return new string[]
		{
			@"
import com.google.android.apps.authenticator.util.EmptySpaceClickableDragSortListView;
import com.google.android.apps.authenticator.util.annotations.FixWhenMinSdkVersion;
import com.google.android.apps.authenticator2.R;
import com.google.common.annotations.VisibleForTesting;
			".Trim(),
		};

		yield return new string[]
		{
			@"
import android.support.v7.widget.Toolbar;
import android.text.Html;
import android.util.Log;
import android.view.ActionMode;
import android.view.ContextMenu;
			".Trim(),
		};
	}

	[Theory]
	[MemberData(nameof(ImportLists))]
	public void Parse_WhenValidPackageName_ReturnsStructureWithCorrectName(string importList)
	{
		var expected = importList.Split(Environment.NewLine).ToList();
		var actual = JavaGrammar.ImportList.Parse(importList);

		Assert.Equal(expected, actual.Select(_ => _.ToString()));
	}
}

A implementação da lista é simples:

public static readonly Parser<List<ImportStatement>> ImportList =
	from statements in ImportStatement.Many().Token()
	select statements.ToList();

Agora que as importações foram tratadas, vamos passar para a próxima estrutura de código em AuthenticatorActivity.java, uma anotação.

Anotações

A próxima estrutura de código em AuthenticatorActivity é a declaração da classe. Ele contém um trecho de código sobre o qual não falamos antes, uma anotação.

As anotações Java são análogas aos atributos C# e têm a seguinte aparência:

@FixWhenMinSdkVersion(11)

Assim como no C#, essas estruturas só podem aparecer antes das declarações e, neste caso, é uma declaração de classe. Precisamos criar a estrutura de dados para acomodar isso e o analisador para produzi-la.

Conforme mencionado anteriormente, estamos construindo interativamente o eBNF. Isso é para tornar nosso analisador mais simples - apenas escrever o código necessário para as estruturas que estão presentes no código-fonte. É por isso que a anotação não foi mencionada antes. Vamos atualizá-lo:

ANNOTATION = "@", IDENTIFIER, "(", ARGUMENT_LIST, ")"

ARGUMENT_LIST = ARGUMENT, { "," ARGUMENT }

ARGUMENT = LITERAL

LITERAL = INTEGER_LITERAL

O eBNF acima é parcial; Por exemplo, a lista de argumentos para a anotação é mais complexa, permitindo outros tipos. Mas até agora só temos o parâmetro inteiro, então vamos nos ater a ele.

A primeira coisa é definir a estrutura de dados de uma anotação. Olhando para ele, você pode imaginar que tem um identificador como nome e, em seguida, a lista de argumentos.

public interface ILiteral
{
	object Value { get; }
}

public class IntegerLiteral  : ILiteral
{
	public IntegerLiteral(int value) 
	{
		Value = value;
	}

	public int Value { get; } 

	object ILiteral.Value => Value;

	public override string ToString()
	{
		return Value.ToString();
	}
}

public class Annotation
{
	public Annotation(string name, List<ILiteral> arguments)
	{
		Name = name;
		Arguments = arguments;
	}

	public string Name { get; }

	public List<Argument> Arguments { get; }

	public override string ToString()
	{
		return $"@{Name}({string.Join(", ", Arguments)})";
	}
}

Introduzimos um pouco de abstração que pode nos poupar algum trabalho mais tarde - deixamos claro que literal pode ser muitas coisas, não apenas inteiros. A conversão para a estrutura correta permitirá que os usuários obtenham o valor digitado - caso contrário, por enquanto, encaixaremos o int e o retornamos como um objeto.

Precisamos criar um analisador para essa nova estrutura - um literal inteiro.

Testes:

public class IntergerLiteralParserTests
{
	[Theory]
	[InlineData(11)]
	public void MyTheory(int value)
	{
		var actual = JavaGrammar.IntegerLiteral.Parse(value.ToString());

		Assert.Equal(value, actual.Value);
	}
}

E o analisador:

public static readonly Parser<IntegerLiteral> IntegerLiteral =
	from digits in Parse.Digit.AtLeastOnce()
	let number = string.Concat(digits)
	let value = int.Parse(number)
	select new IntegerLiteral(value);

Uma coisa no código acima que pode fazer você se perguntar é o AtLeastOnce(). Isso é muito próximo de Many(), com a diferença de que irá falhar na análise quando não houver pelo menos um único dígito. Se não aplicarmos isso aqui, o analisador aceitará uma lista de argumentos vazia.

Agora vamos para a anotação real.

public class AnnotationParserTests
{
	[Theory]
	[InlineData("@Number(11)", new object[] { 11 })]
	public void Parse_WhenAnnotationHasParameters_CorrectParameters(string annotation, object[] parameters)
	{
		var actual = JavaGrammar.Annotation.Parse(annotation);

		Assert.Equal(parameters, actual.Arguments.Cast<object>().ToArray());
	}
}

❗ Esses testes são muito básicos; na maioria dos cenários de produção, sugiro escrever testes que tenham mais condições; por exemplo, poderíamos testar casos como @ SomeAnnotation, dividindo o código em duas linhas, etc. Observe o código que está sendo testado para encontrar lacunas ou riscos e crie os testes correspondentes. Finalmente, com TDD devemos escrever um teste por vez e evoluir iterativamente.

A razão pela qual podemos nos safar com testes tão simples é que sabemos com antecedência todo o código que precisa ser analisado, então podemos testar novamente um caso real e verificar se surgem bugs, mas mesmo assim, eu teria cuidado onde isso não apenas uma postagem no blog.

A implementação irá alavancar o analisador IntegerLiteral que acabamos de codificar:

public static readonly Parser<Annotation> Annotation =
	from at in Parse.Char('@').Once()
	from identifier in Identifier.Token()
	from startList in Parse.Char('(').Token()
	from literal in IntegerLiteral.Token().Optional()
	from endList in Parse.Char(')').Token()
	let arguments = literal.IsDefined
		? new List<ILiteral> { literal.Get() }
		: new List<ILiteral>()
	select new Annotation(identifier, arguments);

Novamente, podemos ver um analisador opcional sendo chamado. Para obter a lista real, precisamos fazer alguma ginástica LINQ - retornar uma lista com um único literal ou uma lista vazia. No futuro, provavelmente criaremos um analisador ArgumentList que deve substituir isso, mas até agora, não há necessidade.

Os testes devem ser 🟢.

Declaração de classe

A próxima coisa no arquivo é a definição real da classe. Vamos decompô-lo:

@FixWhenMinSdkVersion (11)
public class AuthenticatorActivity estende TestableActivity {
// ...

Este é um bom exemplo porque, de cara, lidaremos com extends, um caso comum, mas não basal. A anotação já foi cuidada, então vamos analisar a declaração da classe:

visibility
   │
   │          identifier, class          identifier, base class
   │                   │                          │
┌──┴─┐       ┌─────────┴─────────┐         ┌──────┴───────┐
public class AuthenticatorActivity extends TestableActivity 
       └─┬─┘                       └──┬──┘
         │             interface inheritance keyword
         │
class declaration keyword

Portanto, precisamos expandir o EBNF:

CLASS_DECLARATION = VISIBILITY, "class", IDENTIFIER, { "extends", IDENTIFIER }, "{", (* ommited *), "}";

VISIBILITY = "public" 

❗ Não estamos lidando com outras visibilidades apenas no momento, a não ser para refletir nossa abordagem em evolução. À medida que lidamos com mais casos, expandimos a definição.

Isso deve ser realmente simples, mas precisamos atualizar a estrutura da classe para refleti-la:

public enum Visibility
{
	Public,
}

public class ClassDefinition
{
	public ClassDefinition(Visibility visibility, string name, string? baseClass = null, Annotation? annotation = null)
	{
		Visibility = visibility;
		Name = name;
		BaseClass = baseClass;
		Annotation = annotation;
	}

		public Visibility Visibility { get; }

		public string Name { get; }

		public string? BaseClass { get; }

		public Annotation? Annotation { get; }
}

Algumas coisas a serem observadas: estamos seguindo estritamente o trecho de código que está sendo processado, então, embora haja um membro Visibility, o único valor possível é Public; embora Java permita várias anotações, estamos apenas considerando uma única anotação etc. O motivo pelo qual estou seguindo essa abordagem é que ainda não sabemos se esses casos surgirão dentro do código. Se o fizerem, iremos reescrever o código acima.

Agora vamos criar nossos testes:

public class ClassDefinitionParserTests
{
	[Fact]
	public void Parse_AnnotatedClassWithExtends_CorrectParameters()
	{
		var code = @"
@FixWhenMinSdkVersion(11)
public class AuthenticatorActivity extends TestableActivity
".Trim();

		var actual = JavaGrammar.ClassDefinition.Parse(code);

		Assert.Equal("FixWhenMinSdkVersion", actual.Annotation.Name);
		Assert.Equal(11, actual.Annotation.Arguments[0].Value);
		Assert.Equal(Visibility.Public, actual.Visibility);
		Assert.Equal("AuthenticatorActivity", actual.Name);
		Assert.Equal("TestableActivity", actual.BaseClass);
	}
}

Novamente, os testes lidam apenas com o que vimos até agora. Usei um Fact em vez de uma Theory porque estamos lidando apenas com um único caso. Assim que tivermos mais casos para testar, vou convertê-lo em Theory.

Ah! Estou testando o resultado da análise de anotação, que na verdade está repetindo os testes já feitos no AnnotationParserTests. Poderíamos fazer isso de forma um pouco diferente e apenas ter certeza de que o analisador correto foi chamado, mas por enquanto, vamos manter isso simples e repetir o teste.

Agora, a implementação, embora longa, é simples, apenas analise cada fragmento que vimos na análise acima:

public static readonly Parser<ClassDefinition> ClassDefinition =
	from annotation in Annotation.Token()
	from visibility in Parse.String("public").Token()
	from classKeyword in Parse.String("class").Token()
	from className in Identifier.Token()
	from extendsKeyword in Parse.String("extends").Token()
	from baseClassName in Identifier.Token()
	select new ClassDefinition(Visibility.Public, className, baseClassName, annotation);

Eu cortei alguns cantos aqui, como analisar a visibilidade diretamente. Também lidaremos com isso quando for necessário.

Faça seus testes e aprecie seu 🟢.

Resumo

Com este artigo, concluímos a primeira etapa: podemos analisar todos os elementos de nível superior de um arquivo de origem Java. Você aprendeu um pouco mais sobre como usar Sprache e combinar analisadores e provavelmente aprendeu algumas técnicas sobre como criar código apto para o propósito, usando uma abordagem orientada a testes e evoluindo o código anterior à medida que avançamos em nosso entendimento do domínio do problema, um processo denominado ** descoberta **.

Todo o código produzido até agora foi armazenado em Github. Você pode fazer um fork e usá-lo da maneira que quiser. Para obter a versão exata deste código, use esta tag.

No próximo artigo, começaremos a analisar elementos de classe como construtores, campos e métodos. Estaremos ainda mais focados, lidando apenas com as estruturas de código que aparecem no código e, com sorte, poderemos terminar de analisar nossa primeira classe.

Vejo você na próxima vez!