solo-space

メモ・記録・共有

特定名前空間を禁止するC# Analyzerを実装する

自プロジェクトで特定の名前空間の宣言(もちろん使用も)を禁止する必要性が出てきました。
たとえばMySampleApp.XXXという名前空間は基本OKですがMySampleApp.MyLibはダメでMyLibとしなければいけない、等(MyLibの下にさらに階層があっても同様と考えてください)。
いやそれどないなっとんねんというツッコミがあるのは承知ですが大分簡略化したのでそういうもんと捉えてください。
で、この"MySampleApp.MyLib禁止"をどうにか自動でチェックできないかと考えていたのですが、Analyzerでできそうということがわかりました。

サンプルプロジェクトを以下リポジトリに上げているので適宜参照してください。
https://github.com/Soloful5010/BannedNamespaceSample

目次

方法1:BannedApiAnalyzersを使う

シンプルな「このクラスやメソッドを使ってはいけない」というルールを設定できる汎用AnalyzerとしてMicrosoft.CodeAnalysis.BannedApiAnalyzersがあります。
使い方自体は割と簡単でした。
1. NugetからMicrosoft.CodeAnalysis.BannedApiAnalyzersをインストール 2. プロジェクトにBannedSymbols.txtという名前でテキストファイルを作成
3. 以下をプロジェクトファイルに追加

  <ItemGroup>
    <AdditionalFiles Include="BannedSymbols.txt" />
  </ItemGroup>

ファイルをプロジェクトに明示的に追加しないと動いてなさそうなのがちょっと不安ですが、これで準備OK。
あとはBannedSymbols.txtに、このプロジェクトでどういうクラスやメソッド等を使ってはいけないのか書いていきます。
書き方はこちらのドキュメントに全部載っています。 github.com

例えばサンプルプロジェクトのBannedSymbols.txtには以下のエントリがあり、BannedTypeクラスを使ったら警告が出るようにしています。

T:MySampleApp.Banned.BannedType

実際にBannedTypeを使ってしまっているAppCoreクラスを見てみると警告が出ているのがわかります。 大丈夫そうですね。

ただしBannedApiAnalyzers、安定版は今のところ名前空間に対してはうまく働かないようです。
サンプルプロジェクトには以下のエントリも含まれており、Nプレフィックスのこのエントリは名前空間を対象とするはずです。

N:MySampleApp.Banned

MySampleApp.Banned名前空間を対象としているので、BannedTypeのみならず当該名前空間下のOtherTypeも禁止されるはずです…が
先程のAppCoreクラスの画像をみる限り、OtherTypeについては何も警告が出ていません。

「安定版は」と書いた通り、最新ベータ(3.12.0-beta1.25218.8)を導入するとちゃんと名前空間の禁止が働き、OtherTypeも禁止の警告が出るようになります。

当初の目的に対してはこれで対策でもよかったのですが、名前空間の宣言のところには警告が出ていないのが気になります。

どうせ名前空間下のシンボルを使ったときに出てくるのでいいのですが、せっかくなので名前空間の宣言等にも警告を出したい…という場合は 、次のAnalyzer実装をやることになりそうです。

方法2:Analyzerを実装

方法1ではBan定義テキストを読み込んで警告を出してくれる汎用Analyzerを使いましたが、こちらでは自分でAnalyzerを実装します。
公式のAnalyzer開発テンプレートもあるのですが、こちらの「SuperSimpleAnalyzerをシンプル構成で作る」に沿うと1プロジェクトで実装できます(ありがたい!)。

neue.cc

Analyze対象のプロジェクトと同ソリューションで同梱するパターンの場合、これがよさそうです。

やり方はほぼ参考記事の手順通りでOKです。
サンプルプロジェクトではAnalyzerプロジェクト名はMySampleApp.Analyzerになってます。
あんまり変わったことをしてるわけではないですが、当初の目的通りMySampleApp.MyLibを禁止するAnalyzerは以下のようになりました。

#pragma warning disable RS2008

using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Diagnostics;
using System.Collections.Immutable;

[DiagnosticAnalyzer(LanguageNames.CSharp)]
public class SimpleAnalyzer : DiagnosticAnalyzer {
    public readonly static string[] BannedNamespaces = [
        "MySampleApp.MyLib"
    ];

    private static readonly DiagnosticDescriptor Rule = new DiagnosticDescriptor(
        id: "BANNMSP001",
        title: "Banned namespace",
        messageFormat: "Namespace {0} can't be used",
        category: "Usage",
        defaultSeverity: DiagnosticSeverity.Error,
        isEnabledByDefault: true
        );

    public override ImmutableArray<DiagnosticDescriptor> SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } }

    public override void Initialize(AnalysisContext context) {
        context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
        context.EnableConcurrentExecution();

        // Register action
        context.RegisterSyntaxNodeAction(AnalyzeUsing, SyntaxKind.UsingDirective);
        context.RegisterSyntaxNodeAction(AnalyzeDeclareNamespace, SyntaxKind.NamespaceDeclaration, SyntaxKind.FileScopedNamespaceDeclaration);
    }

    private static void AnalyzeDeclareNamespace(SyntaxNodeAnalysisContext context) {
        NameSyntax? nameSyn = null;
        if (context.Node is NamespaceDeclarationSyntax nds) {
            nameSyn = nds.Name;
        }
        else if (context.Node is FileScopedNamespaceDeclarationSyntax fsnds) {
            nameSyn = fsnds.Name;
        }

        ReportBannedNameSyntax(context, nameSyn);
    }

    private static void AnalyzeUsing(SyntaxNodeAnalysisContext context) {
        if (context.Node is UsingDirectiveSyntax usingNode) {
            ReportBannedNameSyntax(context, usingNode.Name);
        }
    }

    private static void ReportBannedNameSyntax(SyntaxNodeAnalysisContext context, NameSyntax? nameSyn) {
        if (nameSyn == null) return;

        var name = nameSyn.ToString();
        foreach (var banned in BannedNamespaces) {
            if (name.StartsWith(banned)) {
                var diag = Diagnostic.Create(Rule, nameSyn.GetLocation(), banned);
                context.ReportDiagnostic(diag);
            }
        }
    }

}

Analyzerが動いている様子:

今度はちゃんと宣言やusingでも警告が出せています。
またせっかく細かく制御できるのでレベルは警告ではなくエラーで出すようにしました。
ちゃんと動いてますね!

以下、Visual Studioの更新などに合わせて少し元記事とは変えた部分を書いていきます。

変更点

Microsoft.CodeAnalysis.CSharpMicrosoft.CodeAnalysis.Analyzersのバージョンを上げる(等)

Analyzerプロジェクトの方でMicrosoft.CodeAnalysis.CSharpのバージョンを上げないとfile-scope namespace(namespace Foo;スタイルのやつ)がとれなかったのでバージョンを上げてます。
Microsoft.CodeAnalysis.Analyzersの方もそれに合わせて上がってます。

  <ItemGroup>
    <PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
  </ItemGroup>

あとはRS1036警告が出たのでその指示通りに以下を追加しています。

  <PropertyGroup>
    <!--Following warning RS1036-->
    <EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
  </PropertyGroup>

デバッグプロファイルの作成

デバッグ実行のプロファイル設定が専用ウィンドウとなっていて記事と少し変わっています。とはいっても難しいことはなくて…

まずプロジェクトのプロパティ>Debugにあるリンクからプロファイル画面を開きます。

新規プロファイルを作成、Roslyn Componentを選択します。

名前をいい感じに変更してAnalyze対象プロジェクトをターゲットにするようにします。

あとはAnalyzerプロジェクトをスタートアッププロジェクトに設定し、画面上部から先程作成したデバッグプロファイルを選んでからデバッグ実行。

簡単ですね!

おわりに

いやー本当に先駆者の方ありがたいです…🙏

【Neovim】telescope-cocでジャンプ元をjumplistに追加する

Telescope coc definitionsで定義に飛んだ後に元の使用箇所に戻ろうとして<C-o>したら知らんとこにぶっ飛んでってしまうのでちゃんと元の場所に戻れるようにする。

Telescope coc definitions push_cursor_on_edit=true

自キーマップ

vim.keymap.set({ 'n' }, '<Leader>f', '[telescope]', { remap = true })
vim.keymap.set({ 'n' }, '[telescope]gd', '<Cmd>Telescope coc definitions push_cursor_on_edit=true<CR>')
-- 他にも[telescope]*でキーマップ色々が続く

参考: github.com

telescope-coc以外でも独自のpickerを提供するプラグインの場合同様にする必要があると思われる…というか上のPRを見ると組み込みpickerでもmarks・lsp_references・lsp_document_symbolsでしかデフォルトtrueでないのでその他のpickerもおそらくそう。

【TypeScript】グローバル変数の型定義を書く方法

出題

問題:以下の状態で型がHogeのグローバル変数gが存在することを示す型定義を書き足すにはどのようにすればいいでしょうか?

// sample.d.ts
declare class Hoge {
  a: number;
  sayA: () => void;
}

正解:

// sample.d.ts
declare class Hoge {
  a: number;
  sayA: () => void;
}

declare var g: Hoge; // こう

問題:ではHogeの定義が外部モジュールにあってimportする場合、つまり以下の場合に同様のグローバル変数gが存在することを示す型定義を書き足すにはどのようにすればいいでしょうか?

// sample.d.ts
import { Hoge } from 'hoge-module'

正解:

// sample.d.ts
import { Hoge } from 'hoge-module'

// こう
declare global {
  var g: Hoge;
}

まだ色々と慣れていないだけかもしれないけど、ちゃんとこれにたどり着くのに結構時間かかってしまった…

解説

前者と後者では同じ型定義ファイルでも前者はスクリプト、後者はモジュールとして扱われる。違いはtop-level importやexportがあるかどうか。

そしてスクリプト上のvarはグローバルスコープである一方、モジュール上のvarはモジュールスコープ。

www.typescriptlang.org

In TypeScript, just as in ECMAScript 2015, any file containing a top-level import or export is considered a module. Conversely, a file without any top-level import or export declarations is treated as a script whose contents are available in the global scope (and therefore to modules as well).

なので、スクリプトの方はdeclare varでグローバル変数となるけども、モジュールの方ではそのままではダメ。

モジュールでグローバル変数を型定義するには以下の通り。

www.typescriptlang.org

ややこしいかもしれないポイント

躓いたのはおそらく、型定義を書きたかっただけなのに型定義ファイルにもスクリプト/モジュールの区別が発生したから(そして発生することを知らなかったから)だと思う。

jsにコンパイルされるファイルであればもちろんjsと同様にスクリプト・モジュールの扱いがあるのはわかるのだけど、型定義だけ独立しているファイルにその区別が発生するのがなかなかしっくりこなかった…


どこかでそもそも型定義ファイルはあくまでtsjsのトランスパイル時にjsファイルと合わせて生成されることを想定しており型定義だけを独立して書いたり扱うのは当初の想定外という話を見た気がする(ソースが見つかれば貼る)
であれば型定義ファイルもトランスパイル元のts(そしてトランスパイルの結果のjs)がスクリプトならスクリプト、モジュールならモジュールという区別があるのは自然に思う。

※追記

import types(import(...)型)を使用して以下のようにしてもOK

sample.d.tsがモジュールではなくグローバルスコープのままになる点でこちらの方がよさそう

// sample.d.ts
declare var g: import('hoge-module').Hoge;

参考文献

import typesについて www.typescriptlang.orgimport type from ...と紛らわしく検索はほぼそちらの情報で埋まっているので出てきにくい) stackoverflow.com zenn.dev stackoverflow.com